最新资讯
dev.to 精简 MCP server 工具数量:更少工具、更小影响面,双重收益 dev.to 为什么 git checkout 无法撤销 AI 编码 agent 的改动? wechat AI短剧门槛降降降!实测即梦短剧工作室:剧本分镜预演一条龙,伙伴计划长期护航 dev.to 让模型改写文档前,先量化事实保真度 dev.to 回放 153 个 Claude Code 与 Codex 会话,审计 Agent 的生产影响范围 dev.to 银行 77 意图分类最便宜 LLM:GPT-6.1 Terra 每次成功任务 $0.000727 theinformation.com Chesky的副业:Airbnb向CEO新设AI实验室出售IP theverge.com OpenAI 宣布数据中心项目不再与公职人员签署保密协议 dev.to NVIDIA 发布四个开放 Cosmos 3 仿真模型 dev.to 真实 MCP 客户端击穿我们服务器的五种方式 theverge.com Airbnb 正在开发 AI 旅行 agent,计划 2027 年上半年推出 wechat 这可能是美国AI最魔幻的一天。
多元动态
登录 注册
大模型 dev.to · @musaib khan

在 Next.js 应用中接入远程 MCP 服务器:含 OAuth 完整实现

原文 我的收藏

文章指出多数 MCP 教程止步于本地 stdio 进程,而远程 MCP 服务器需要 OAuth 才能被 Claude 或 ChatGPT 通过 HTTPS 与浏览器登录接入。作者以自建表单工具 Deoochform 为例,基于 Next.js 16 App Router 和官方 SDK 实现四个 HTTP 接口:MCP 端点 POST /api/mcp、受保护资源元数据、授权服务器元数据,以及 /authorize、/token、/register 等 OAuth 端点。

AI推荐理由 面向开发者的实操文章,讲解如何在 Next.js 16 App Router 中实现远程 MCP 服务器及 OAuth 授权流程,覆盖受保护资源元数据、授权服务器元数据、/authorize、/token、/register 等端点,对构建可被 Claude 或 ChatGPT 通过 HTTPS 接入的 MCP 服务有直接参考价值;内容为作者自述实现,未提供独立验证。

68

正文

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, 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. The shape of it 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. 1. The MCP endpoint The SDK ships a transport that speaks Web-standard Request/Response, which is exactly what an App Router route handler deals in: 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 }; Enter fullscreen mode Exit fullscreen mode 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. CORS, or: why your connector silently never connects 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. 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", }; Enter fullscreen mode Exit fullscreen mode 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. 2. The 401 that starts the dance An unauthenticated call must not just return 401. It has to say where to go, using WWW-Authenticate per RFC 9728: const metadataUrl = `${origin}/.well-known/oauth-protected-resource/api/mcp`; return Response.json( { error: "Unauthorized" }, { status: 401, headers: { "WWW-Authenticate": `Bearer resource_metadata="${metadataUrl}"` }, }, ); Enter fullscreen mode Exit fullscreen mode 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], }); } Enter fullscreen mode Exit fullscreen mode 3. Being your own authorization server 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"], }); } Enter fullscreen mode Exit fullscreen mode 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.) Dynamic client registration, honestly RFC 7591 says a client can register itself. In practice, for a public client authenticated by PKCE, there is nothing meaningful to store: 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", }); } Enter fullscreen mode Exit fullscreen mode 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. The attack PKCE does not stop 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: 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 } Enter fullscreen mode Exit fullscreen mode 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. PKCE Twelve lines, all of it standard library: 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; } Enter fullscreen mode Exit fullscreen mode 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. 4. Tokens and the caller 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(); Enter fullscreen mode Exit fullscreen mode 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. The part I got wrong first 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 }); Enter fullscreen mode Exit fullscreen mode Capability decisions are URL-shaped, not runtime-shaped. Plan for that before you submit anywhere. Checklist Stateless transport, sessionIdGenerator: undefined. Server built per request with the caller baked in. CORS on every response, including Expose-Headers. 401 carries WWW-Authenticate with the resource metadata URL. Protected-resource metadata nested under each endpoint's own path. Public client, PKCE, no client secret. Registration endpoint can be trivial. Say why in a comment. A consent page on /authorize, approved by same-origin POST. PKCE alone does not stop a crafted-link attack, because there the attacker holds the verifier. 303 on the post-approval redirect, not 307. Token hashes in the database, RLS does the rest. Decide capabilities per URL before you list anywhere. 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 shows it from the user side if you want to see what the flow feels like before building your own.

来源报道 · 1 篇 · 以下链接均前往原始媒体

← 返回精选动态 原文

更多内容