# Adding a remote MCP server to a Next.js app, OAuth and all

> Source: <https://dev.to/musaib_khan_acf62e837beb3/adding-a-remote-mcp-server-to-a-nextjs-app-oauth-and-all-jn8>
> Published: 2026-09-10 10:44:53+00:00

Most "add MCP to your app" posts stop at stdio: a local process, a config file,

a token you paste in. That works on your laptop and nowhere else. A *remote*

MCP server, the kind Claude or ChatGPT connects to over HTTPS with a browser

sign-in, needs OAuth, and the interesting parts are the ones no tutorial covers.

I shipped one for [Deoochform](https://deoochform.com), a form builder where the

whole point is that you can build the form by asking an assistant instead of

dragging fields around. So the MCP server is not a side feature, it is the

product surface. That forced me to get the auth story right rather than

hand-waving it with a pasted API key.

Here is the whole thing, Next.js 16 App Router, no framework beyond the official

SDK.

Four HTTP surfaces:

`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.
A client that has never seen your server walks all four in order, unprompted.

That discovery chain is the whole reason a user can type a URL into Claude and

get a browser sign-in instead of a token prompt.

The SDK ships a transport that speaks Web-standard `Request`/` Response`, which is

exactly what an App Router route handler deals in:

``` js
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";

async function handle(request: Request) {
  const actor = await resolveActor(request);
  if (!actor) return unauthorized(request);

  const server = createMcpServer(actor);
  const transport = new WebStandardStreamableHTTPServerTransport({
    sessionIdGenerator: undefined,
    enableJsonResponse: true,
  });

  await server.connect(transport);
  return transport.handleRequest(request);
}

export { handle as GET, handle as POST, handle as DELETE };
```

Two things worth pausing on.

`sessionIdGenerator: undefined` makes the server stateless. On Vercel or any

serverless host, consecutive requests land on different instances, so there is

nowhere for a session to live. Stateless is not a downgrade here, it is the only

thing that works.

The server is constructed **per request, with the actor baked in**. Not a

module-level singleton with the user passed into each tool call. Every tool

closes over the caller's identity, so there is no code path where a tool can

read a row belonging to somebody else. It costs one object allocation per

request and removes an entire category of bug.

Browser-based clients (ChatGPT, Claude on the web) call your endpoint

cross-origin. The browser fires a preflight `OPTIONS` first, and if that fails

the real request never happens. You see nothing in your logs, and the client

says something unhelpful about being unable to connect.

``` js
const CORS_HEADERS = {
  "Access-Control-Allow-Origin": "*",
  "Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
  "Access-Control-Allow-Headers":
    "Content-Type, Authorization, mcp-session-id, mcp-protocol-version, Last-Event-ID",
  "Access-Control-Expose-Headers": "mcp-session-id, mcp-protocol-version",
};
```

`Expose-Headers` is the one people miss. Without it the browser hides the MCP

protocol headers from the client even though your server sent them.

An unauthenticated call must not just return 401. It has to say where to go,

using `WWW-Authenticate` per RFC 9728:

``` js
const metadataUrl =
  `${origin}/.well-known/oauth-protected-resource/api/mcp`;

return Response.json(
  { error: "Unauthorized" },
  {
    status: 401,
    headers: { "WWW-Authenticate": `Bearer resource_metadata="${metadataUrl}"` },
  },
);
```

This single header is the trigger for the entire browser sign-in flow. Return a

bare 401 and the client concludes your server is broken rather than

password-protected.

Note the metadata path: RFC 9728 nests it under the resource's own path. If you

serve two MCP endpoints, `/api/mcp` and `/api/mcp/v2`, each needs its own

document at its own nested path. They are not interchangeable.

```
// /.well-known/oauth-protected-resource/api/mcp/route.ts
export async function GET() {
  return NextResponse.json({
    resource: `${origin}/api/mcp`,
    authorization_servers: [origin],
  });
}
```

You are probably already sitting on a session system. You do not need Auth0 for

this. The metadata document is a static JSON file plus three routes:

```
export async function GET() {
  return NextResponse.json({
    issuer: origin,
    authorization_endpoint: `${origin}/authorize`,
    token_endpoint: `${origin}/token`,
    registration_endpoint: `${origin}/register`,
    response_types_supported: ["code"],
    grant_types_supported: ["authorization_code"],
    code_challenge_methods_supported: ["S256"],
    token_endpoint_auth_methods_supported: ["none"],
  });
}
```

`token_endpoint_auth_method: "none"` is correct and not a shortcut. An MCP

connector is a *public* client. It ships to end users and cannot keep a secret,

so PKCE, not a client secret, is what proves the token exchange came from the

same client that started the flow. (If you also serve confidential clients, say

a Zapier integration, add `client_secret_basic` and `client_secret_post`

alongside it.)

RFC 7591 says a client can register itself. In practice, for a public client

authenticated by PKCE, there is nothing meaningful to store:

``` js
export async function POST(request: Request) {
  const body = await request.json().catch(() => ({}));
  return NextResponse.json({
    client_id: "dfc_" + randomBytes(9).toString("base64url"),
    client_name: body.client_name ?? "MCP Client",
    redirect_uris: body.redirect_uris ?? [],
    grant_types: ["authorization_code"],
    response_types: ["code"],
    token_endpoint_auth_method: "none",
  });
}
```

That is the entire endpoint. It mints an id and hands it back. A clients table

would be ceremony: nothing downstream consults it.

But be careful about what you conclude from that. "We do not register clients,

PKCE covers it" is the sentence I would have written before I thought it

through, and it is wrong. See the next section.

PKCE binds an authorization code to whoever started the flow. The usual mental

model is that this makes an unregistered `redirect_uri` safe, because a

stolen code is useless without the verifier.

That model breaks when **the attacker is the one who started the flow**. They

craft an `/authorize` link with their own `redirect_uri` and their own

`code_challenge`, and send it to a signed-in victim. The victim's browser

follows it. The code is minted against the victim's session, redirects to the

attacker's callback, and the attacker exchanges it with the verifier they chose.

An access token for someone else's account, from one click. PKCE did its job

perfectly and protected nobody, because the attacker held the verifier all

along.

So the redirect cannot be automatic. `GET /authorize` renders a consent page

instead, and the code is only minted by a `POST` from that page:

``` js
export async function POST(request: Request) {
  const origin = request.headers.get("origin");
  if (origin !== url.origin) {
    return NextResponse.json(
      { error: "invalid_request", error_description: "Cross-origin approval refused." },
      { status: 403 },
    );
  }
  // ... re-read params, re-read the session, then mint
}
```

Two properties do the work. The approval is a step a crafted link cannot

perform on the victim's behalf. And `Origin` is sent on every form POST and

cannot be forged by page script, so a cross-site auto-submitting form is not an

approval either.

The consent page names the host the code is about to go to, not the full URI.

The host is the part that actually matters to the decision, and a long URI just

gives someone something to skim past.

Re-read the session inside the POST rather than trusting a hidden field. The

session is the only thing that says whose account the code is for.

One caveat on the redirect itself: use a **303**, not a 307. Approval arrives as

a POST, and 307 preserves the method, so the browser would POST the code to a

callback that only answers GET. The client reports a bare "Bad Request" and it

looks like the client's bug rather than yours.

A confidential client with a pre-registered `redirect_uri` (a Zapier

integration, say) can skip the prompt. There is no third party to consent to,

because the caller could not have changed the destination.

Twelve lines, all of it standard library:

``` js
import { randomBytes, createHash } from "crypto";

const CODE_TTL_MS = 5 * 60 * 1000;

export const generateAuthCode = () =>
  "dfc_" + randomBytes(24).toString("base64url");

export const codeExpiry = () =>
  new Date(Date.now() + CODE_TTL_MS).toISOString();

export function verifyPkce(verifier: string, challenge: string) {
  return createHash("sha256").update(verifier).digest("base64url") === challenge;
}
```

Five-minute code TTL, single use, deleted on exchange. `/authorize` runs behind

your normal session check, so an unauthenticated user hits your existing login

first and comes back. That is the whole reason the user never sees a token.

If you also compare client secrets anywhere, use `timingSafeEqual`, and check

lengths yourself first. `timingSafeEqual` throws on a length mismatch, and an

uncaught throw becomes a 500 that leaks the secret's length.

The token you issue can just be a row. Store a hash, never the token:

```
const { data: token } = await admin
  .from("api_tokens")
  .select("id, user_id, revoked_at, profiles(id, email, role, plan)")
  .eq("token_hash", hashToken(raw))
  .is("revoked_at", null)
  .maybeSingle();
```

One query gets you validity and the caller's identity, role, and plan. The

connection then acts as that user, with their permissions, which means your

existing row-level security applies to MCP traffic for free. No parallel

permission model to keep in sync.

I originally planned one endpoint with a capability flag, so a listed directory

connector could be restricted while my own stayed full-featured. It does not

work: an app directory registers its OAuth client against a base URL and then

freezes it. Whatever a listed URL is allowed to do has to be settled *before*

you list it.

So there are two endpoints, `/api/mcp` and `/api/mcp/v2`, same server behind

different options. The public one cannot see or create payment fields at all,

which means nothing built through the directory listing can collect money. Two

routes, five lines each:

```
export const { GET, POST, DELETE, OPTIONS } =
  mcpHandlers({ path: "/api/mcp/v2", payments: false });
```

Capability decisions are URL-shaped, not runtime-shaped. Plan for that before

you submit anywhere.

`sessionIdGenerator: undefined`.` Expose-Headers`.` WWW-Authenticate` with the resource metadata URL.
None of this is much code. It is roughly 150 lines across six files. The hard

part was working out which pieces of the OAuth spec actually apply to a public

client that a user connects by pasting a URL, and which are ceremony.

The result is what I wanted: you paste

`https://deoochform.com/api/mcp/v2` into Claude, a browser tab opens, you sign

in, and you are done. No token to copy, nothing to store. The

[MCP server docs](https://deoochform.com/docs/mcp-server) shows it from the user

side if you want to see what the flow feels like before building your own.
