1. Resource Server로서의 MCP Server: 정확히 무엇을 설정하나
지난 강의에서는 토큰을 발급하는 Auth Server를 구성했습니다. 이제 이 짝의 다른 쪽, 즉 토큰을 수신하고 검증하는 Resource Server 역할의 MCP 서버를 다룹니다.
OAuth 2.1 관점에서 여러분의 MCP 서버는 Resource Server입니다. MCP 도구와 사용자 데이터 같은 ‘리소스’를 보관하고, 헤더 Authorization: Bearer ...에 담긴 access token과 함께 오는 요청을 받습니다. tool을 실행하기 전에, 토큰이 진짜인지, 기간이 만료되지 않았는지, 신뢰할 수 있는 Auth Server가 발급했는지, 그리고 바로 이 MCP 서버를 대상으로 발급되었는지와 필요한 권한(scope)이 있는지를 확인해야 합니다.
두 가지 수준을 구분하는 것이 중요합니다:
- 전송(transport) 레이어 — 여기서 HTTP 헤더와 토큰을 처리합니다. 이 레이어에서 여러분은:
- Authorization: Bearer를 수신/파싱하고,
- 토큰이 없거나 잘못되면 401 Unauthorized와 함께 WWW-Authenticate: Bearer ...를 반환하며,
- 유효한 토큰이면 사용자 컨텍스트를 구성합니다.
- MCP SDK 레이어 — JWT를 알 필요가 없습니다. 이미 인증된 호출만 받고, handler 내부에서 ctx.userId, ctx.scopes 등을 사용할 수 있습니다.
비유하자면, MCP SDK는 주방의 요리사이고 OAuth 미들웨어는 출입문 경비입니다. 요리사는 여권을 검사하지 않고, 주문만 처리합니다.
실습 예시는 GiftGenius를 계속 사용하겠습니다. MCP 서버는 http://localhost:3000에서 list_my_gifts 도구를 제공하고, Auth Server(예: Keycloak 또는 커스텀 미니 AS)는 http://localhost:4000에 둡니다.
2. .well-known/oauth-protected-resource: MCP 리소스의 ‘명함’
리소스에 .well-known이 필요한 이유
ChatGPT(또는 MCP Jam)가 처음 MCP 서버에 접속해 401을 받으면, 두 가지를 알아야 합니다:
- 어디로 토큰을 받으러 가야 하는지,
- 이 리소스가 어떤 권한을 지원하는지.
클라이언트에 이런 정보를 ‘하드코딩’하지 않기 위해 discovery 엔드포인트를 사용합니다:
GET /.well-known/oauth-protected-resource
이 엔드포인트는 RFC 9728에 정의된 보호된 리소스 메타데이터(Protected Resource Metadata)를 JSON으로 반환합니다.
GiftGenius의 예시:
{
"resource": "http://localhost:3000",
"authorization_servers": ["http://localhost:4000"],
"scopes_supported": ["gifts:read", "gifts:write"],
"bearer_methods_supported": ["header"]
}
OpenAI의 가이드도 거의 동일한 예시를 보여주며, 실제 환경에서는 HTTPS와 실제 도메인을 사용합니다.
클라이언트(ChatGPT/Jam)는 이 문서를 읽고 다음을 이해합니다:
- 토큰의 audience가 http://localhost:3000이어야 한다는 것,
- 어떤 authorization_servers(issuer URL)와 상호작용해야 하는지,
- 지원되는 scope 목록(동의 화면과 프롬프트 구성에 유용함).
메타데이터 필드 분석
주요 필드 요약:
| 필드 | 의미 |
|---|---|
|
MCP 서버의 정규 HTTPS/HTTP 식별자. 나중에 토큰의 aud와 일치합니다. |
|
Auth Server(issuer) URL 목록. 클라이언트가 이곳에서 OAuth/OIDC 메타데이터를 가져옵니다. |
|
지원되는 scope 배열. 클라이언트의 UX와 올바른 토큰 요청을 위해 필요합니다. |
|
토큰 전달 방식: 보통 ["header"]. 즉, Authorization: Bearer .... |
추가로 resource_documentation, jwks_uri, introspection_endpoint 등을 게시하기도 하지만, 기본 시나리오에는 위 네 가지로 충분합니다.
핵심 포인트: resource는 Auth Server가 토큰의 aud에 넣는 값과 일치해야 합니다. 일치하지 않으면 MCP 클라이언트(그리고 여러분의 서버)도 토큰을 거부합니다.
Next.js 16에서 .well-known 구현
MCP 서버가 Next.js 앱(Apps SDK 백엔드, 포트 3000)에 있다고 합시다. 가장 간단한 방법은 app/.well-known/oauth-protected-resource/route.ts에 route handler를 만드는 것입니다:
// app/.well-known/oauth-protected-resource/route.ts
import { NextResponse } from "next/server";
export async function GET() {
const body = {
resource: "http://localhost:3000",
authorization_servers: ["http://localhost:4000"],
scopes_supported: ["gifts:read", "gifts:write"],
bearer_methods_supported: ["header"],
};
return NextResponse.json(body);
}
프로덕션에서는 resource가 MCP 서버의 프로덕션 HTTPS URL(예: https://mcp.giftgenius.com)이어야 하며, IdP가 발급하는 토큰의 aud와 일치해야 합니다.
3. WWW-Authenticate와 401: MCP가 ‘토큰 필요’를 알리는 방법
이미 .well-known/oauth-protected-resource에 리소스의 ‘명함’을 만들었습니다. 이제 MCP 서버가 401과 WWW-Authenticate 헤더를 통해 그 명함으로 가야 함을 어떻게 알려주는지 보겠습니다.
기본 시나리오: 토큰 없이 왔을 때
ChatGPT가 처음으로 list_my_gifts 도구를 호출한다고 가정해 봅시다. 네트워크 요청은 대략 다음과 같습니다:
GET /mcp/tools/list_my_gifts HTTP/1.1
Host: localhost:3000
토큰이 없습니다. MCP 서버는 403이나 임의의 HTML 페이지를 조용히 내보내면 안 됩니다. OAuth 세계에서 보호된 리소스의 올바른 동작은 401 Unauthorized를 반환하고, WWW-Authenticate 헤더를 통해 클라이언트가 어떻게 인증해야 하는지 설명하는 것입니다.
올바른 응답 예:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="http://localhost:3000/.well-known/oauth-protected-resource", scope="gifts:read"
Content-Type: application/json
{"error":"unauthorized","error_description":"Missing or invalid access token"}
중요한 포인트:
- Bearer 스킴은 OAuth Bearer 토큰을 원한다는 의미입니다.
- resource_metadata 파라미터는 .well-known/oauth-protected-resource의 URL을 가리킵니다.
- scope 파라미터는 필요한 최소 권한(예: gifts:read)을 알려줍니다.
MCP Jam과 ChatGPT는 이 헤더를 읽을 수 있습니다. 이를 보면 다음을 수행합니다:
- .well-known/oauth-protected-resource를 호출합니다.
- authorization_servers에서 Auth Server를 찾고, 그 OpenID/OAuth 메타데이터를 확인합니다.
- Authorization Code + PKCE 플로우를 시작하고, 사용자 로그인 페이지를 열어 토큰을 획득합니다.
즉, WWW-Authenticate는 트리거입니다. 이것이 없으면 클라이언트는 여기에 OAuth가 있다는 사실을 알아채지 못합니다.
401 응답을 위한 미들웨어(Next.js)
모든 보호된 엔드포인트에서 사용할 작은 유틸리티를 작성해 봅시다. 먼저 응답을 구성하는 함수입니다:
// lib/authResponses.ts
import { NextResponse } from "next/server";
export function unauthorized(scope?: string) {
const wwwAuth = [
`Bearer resource_metadata="http://localhost:3000/.well-known/oauth-protected-resource"`,
scope ? `scope="${scope}"` : null,
]
.filter(Boolean)
.join(", ");
return new NextResponse(
JSON.stringify({
error: "unauthorized",
error_description: "Missing or invalid access token",
}),
{
status: 401,
headers: {
"WWW-Authenticate": wwwAuth,
"Content-Type": "application/json",
},
}
);
}
이제 어떤 라우트(예: MCP 엔드포인트)든 return unauthorized("gifts:read")만 호출하면 클라이언트는 올바른 challenge를 받게 됩니다. unauthorized() 함수는 NextResponse(표준 Response와 호환)를 반환합니다. 이후 예제에서는 이 객체를 예외처럼 던지고, route handler에서 Response인지 확인해 캐치함으로써 매 라우트마다 401 응답을 중복 구성하지 않도록 하겠습니다.
4. Bearer 토큰 수신 및 검증
이제 핵심입니다. Bearer 토큰을 어떻게 수신하고 검증할까요.
검증을 어디서 할까
여러분의 MCP 전송 레이어는 보통 다음 중 하나로 구현됩니다:
- Next.js route handler(app/mcp/route.ts)로 POST를 받고 MCP SDK에 위임하는 경우,
- Express/Fastify 서버가 /mcp를 리슨하고 JSON을 MCP 핸들러에 전달하는 경우.
이 모든 경우에 HTTP 레이어가 해야 할 일은 다음과 같습니다:
- 헤더에서 Authorization을 꺼내고,
- 없거나 잘못되면 우리의 unauthorized를 통해 401을 반환하며,
- 성공이면 컨텍스트 객체(userId, scopes, roles)를 구성해 MCP SDK에 전달합니다(핸들러 인자/컨텍스트로).
MCP SDK(예: @modelcontextprotocol/sdk) 자체는 JWT가 무엇인지 몰라도 됩니다. 이 부분은 여러분의 책임입니다.
검증 방식: JWT vs introspection
대표적으로 두 가지가 있습니다:
- Auth Server의 JWK 키로 서명과 클레임을 로컬에서 검증하는 방식(JWT).
- 권한 서버의 /introspect로 호출해 “이 토큰이 아직 유효한가? 어떤 scope를 갖고 있나?”를 묻는 방식.
본 강의에서는 Auth Server가 JWT를 발급하고 jwks_uri를 게시한다고 가정하며, MCP 서버는 서명과 클레임을 로컬에서 검증합니다(더 빠르고 자율적).
TypeScript용 verifyAccessToken 유틸리티
ESM 친화적인 인기 라이브러리 jose를 사용하겠습니다. 대략 다음과 같은 helper가 필요합니다:
// lib/verifyAccessToken.ts
import { jwtVerify, createRemoteJWKSet } from "jose";
const JWKS = createRemoteJWKSet(
new URL("http://localhost:4000/.well-known/jwks.json")
);
const EXPECTED_ISS = "http://localhost:4000";
const EXPECTED_AUD = "http://localhost:3000";
export async function verifyAccessToken(token: string) {
const { payload } = await jwtVerify(token, JWKS, {
issuer: EXPECTED_ISS,
audience: EXPECTED_AUD,
});
return {
sub: String(payload.sub),
scopes: String(payload.scope || "").split(" ").filter(Boolean),
raw: payload,
};
}
이 helper에서 우리는 다음을 수행합니다:
- jwks_uri로 Auth Server의 JWK 키를 가져오고,
- 서명과 표준 클레임(iss, aud)을 검증하며,
- sub(user id)와 scope(공백으로 구분된 문자열이므로 split(" "))을 추출합니다.
audience는 우리의 .well-known/oauth-protected-resource에 있는 resource와 일치해야 하며, 이것이 곧 이 토큰이 바로 우리 MCP 서버를 위해 발급되었음을 보장합니다.
Authorization 헤더의 간단한 검증
이제 헤더에서 토큰을 꺼내 verifyAccessToken으로 돌리는 작은 helper를 만듭니다:
// lib/getUserFromRequest.ts
import type { NextRequest } from "next/server";
import { unauthorized } from "./authResponses";
import { verifyAccessToken } from "./verifyAccessToken";
export async function getUserFromRequest(req: NextRequest) {
const auth = req.headers.get("authorization") || "";
const [, token] = auth.split(" ");
if (!token) throw unauthorized("gifts:read");
try {
return await verifyAccessToken(token);
} catch {
throw unauthorized("gifts:read");
}
}
주의: 여기서는 unauthorized(...)(즉, Response 객체)를 예외처럼 던집니다. 이렇게 하면 route handler에서 간단히 이를 캐치해 응답으로 반환할 수 있습니다.
5. audience와 scope: 토큰을 리소스와 동작에 매핑
Audience(aud): 토큰이 ‘누구를 위한 것인지’
aud 클레임은 토큰이 이 리소스를 위한 것인지에 답합니다. 우리의 경우:
- Auth Server는 토큰의 aud를 http://localhost:3000으로 설정합니다.
- 우리의 .well-known/oauth-protected-resource는 resource: "http://localhost:3000"을 게시합니다.
- verifyAccessToken이 이를 검증합니다.
토큰이 다른 리소스(예: https://api.other-app.com)를 대상으로 했다면, MCP 서버는 “주소가 다름”으로 거부해야 합니다.
전형적인 실수는 resource와 aud를 동기화하지 않는 것입니다. 겉으론 다 맞는 것 같지만 ChatGPT가 계속 401을 받게 됩니다. 이는 “자주 하는 실수” 섹션에서 다시 다룹니다.
Scopes: ‘무엇을 할 수 있는지’
토큰의 scope 클레임은 사용자가 클라이언트에 부여한 권한 목록입니다. 예제에서는:
- gifts:read — 자신의 선물 목록을 읽을 권한,
- gifts:write — 선물을 생성/수정할 권한.
이 값들은 .well-known/oauth-protected-resource의 scopes_supported로 나타나며, 클라이언트가 미리 어떤 권한을 요청할지 알 수 있게 합니다.
권한 서버의 discovery 문서(.well-known/openid-configuration)도 scopes_supported를 게시하지만, 이는 IdP의 전역 scope 목록입니다(리소스 서버의 .well-known/oauth-protected-resource와 혼동하지 마세요).
이 두 목록을 혼동하지 마세요. 리소스의 scopes_supported는 MCP 서버에 필요한 권한을, IdP의 scopes_supported는 제공자의 전역 scope ‘카탈로그’를 의미합니다. 클라이언트는 보통 이 둘의 교집합을 사용합니다.
MCP 서버 수준에서는 다음이 필요합니다:
- 각 도구마다 필요한 scope를 정하고,
- 도구가 호출될 때마다 토큰이 그 scope를 포함하는지 검증합니다.
이를 위한 helper:
// lib/requireScope.ts
import { unauthorized } from "./authResponses";
export function requireScope(
user: { scopes: string[] },
needed: string[]
) {
const hasAll = needed.every((s) => user.scopes.includes(s));
if (!hasAll) throw unauthorized(needed.join(" "));
}
이제 requireScope(user, ["gifts:read"])를 도구 실행 전에 호출할 수 있습니다.
6. MCP 도구와 연결하기: 토큰에서 list_my_gifts까지
Next.js에서 MCP 라우트
어떤 SDK를 기반으로 한 MCP 서버가 있고 HTTP 요청을 처리한다고 가정합시다. Next.js 관점에서는 다음과 비슷할 수 있습니다:
// app/api/mcp/route.ts
import { NextRequest } from "next/server";
import { unauthorized } from "@/lib/authResponses";
import { getUserFromRequest } from "@/lib/getUserFromRequest";
import { mcpServer } from "@/lib/mcpServer";
export async function POST(req: NextRequest) {
try {
const user = await getUserFromRequest(req);
const body = await req.json();
const result = await mcpServer.handle(body, { user });
return Response.json(result);
} catch (err) {
if (err instanceof Response) return err; // unauthorized(...)
console.error(err);
return unauthorized();
}
}
여기서 중요한 점:
- getUserFromRequest로 토큰에서 사용자와 scope를 추출하고,
- 이를 { user } 컨텍스트로 MCP 서버에 전달하며,
- 토큰이 없거나 오류가 나면 WWW-Authenticate가 포함된 우리의 401을 반환합니다.
MCP SDK의 구체 API는 다를 수 있지만, 핵심 아이디어는 같습니다. 즉, MCP 호출을 미들웨어로 감싸 “누가” 호출하는지 이미 아는 상태에서 처리하게 합니다.
list_my_gifts 도구의 scope 검증
이제 도구 구현을 살펴봅시다. MCP용 TypeScript SDK를 사용한다고 가정하고, 대략 다음과 같습니다:
// lib/mcpServer.ts (발췌)
import { createMcpServer } from "@modelcontextprotocol/sdk";
import { requireScope } from "./requireScope";
export const mcpServer = createMcpServer<{ user: any }>();
mcpServer.registerTool(
"list_my_gifts",
{
title: "List my gifts",
description: "Shows your saved gift ideas.",
inputSchema: { type: "object", properties: {}, additionalProperties: false },
},
async (_input, ctx) => {
requireScope(ctx.user, ["gifts:read"]);
const gifts = await loadGiftsForUser(ctx.user.sub);
return {
content: [{ type: "text", text: `Found ${gifts.length} gifts` }],
structuredContent: { gifts },
};
}
);
세 가지 핵심 단계:
- 본 실행 전에 gifts:read를 요구합니다.
- 토큰에서 온 사용자 식별자 ctx.user.sub를 사용합니다.
- 해당 사용자에 대한 데이터만 반환합니다.
이렇게 하면 도구가 ‘공용 API’가 아니라 Auth Server의 Identity에 연결된 개인화된 도구가 됩니다.
7. 흐름 요약: 401부터 성공 호출까지
지금까지를 정리하며, 보호된 MCP 서버가 구현하는 미니 시퀀스를 그려 보겠습니다.
sequenceDiagram
participant ChatGPT
participant MCP as MCP Server (3000)
participant AS as Auth Server (4000)
ChatGPT->>MCP: POST /api/mcp (no Authorization)
MCP-->>ChatGPT: 401 + WWW-Authenticate: Bearer resource_metadata=...
ChatGPT->>MCP: GET /.well-known/oauth-protected-resource
MCP-->>ChatGPT: { resource, authorization_servers, scopes_supported }
ChatGPT->>AS: GET /authorize?scope=gifts:read&resource=...
AS-->>ChatGPT: redirect with ?code=XYZ
ChatGPT->>AS: POST /token (code + code_verifier)
AS-->>ChatGPT: { access_token, scope, ... }
ChatGPT->>MCP: POST /api/mcp Authorization: Bearer token
MCP->>MCP: verify JWT (iss, aud, exp, scope)
MCP-->>ChatGPT: tool result for this user
Auth Server 요청의 resource 파라미터에 주목하세요. 이는 토큰의 aud로 복사되며, .well-known/oauth-protected-resource의 resource와 일치해야 합니다.
8. curl로 간단히 확인하기
확인을 위해 두 가지 요청을 직접 해볼 수 있습니다.
첫 번째 — 토큰 없이 MCP 호출 시도:
curl -i http://localhost:3000/api/mcp \
-H "Content-Type: application/json" \
-d '{"method":"tools/call","params":{"name":"list_my_gifts","arguments":{}}}'
401 상태와 resource_metadata, scope="gifts:read"가 포함된 WWW-Authenticate를 기대합니다.
두 번째 — Auth Server에서 받은 유효한 토큰과 함께:
curl -i http://localhost:3000/api/mcp \
-H "Authorization: Bearer abc123" \
-H "Content-Type: application/json" \
-d '{"method":"tools/call","params":{"name":"list_my_gifts","arguments":{}}}'
이제 abc123이 올바른 iss, aud="http://localhost:3000"를 갖고 scope에 gifts:read가 포함된 유효한 JWT라면, 도구의 JSON 응답을 받고 structuredContent.gifts에 현재 사용자의 선물이 담길 것입니다.
9. MCP Server를 보호된 리소스로 구성할 때의 자주 발생하는 실수
아래는 우리가 방금 구현한 코드(.well-known, WWW-Authenticate, 토큰 검증과 scope 확인)를 만들 때 자주 겪는 함정들입니다.
오류 1: resource와 audience 불일치.
.well-known/oauth-protected-resource에는 하나의 resource 값을 쓰고, Auth Server는 토큰의 aud에 다른 값을 넣는 경우가 잦습니다. 그 결과, 서명과 만료가 올바르더라도 jwtVerify가 토큰을 거부합니다. 특히 MCP 서버의 도메인/포트를 바꾸면서 .well-known이나 Auth Server 설정 중 하나를 업데이트하지 않으면 쉽게 깨집니다. 본 예시에서는 resource 필드의 http://localhost:3000과 verifyAccessToken 내부의 EXPECTED_AUD가 같은 문자열입니다. 아예 RESOURCE_ID 같은 단일 상수를 만들어 두 군데에서 재사용하세요.
오류 2: 401에서 WWW-Authenticate 누락.
개발자가 401 또는 403만 반환하고 WWW-Authenticate 헤더를 빼먹는 경우가 있습니다. 브라우저 입장에서는 괜찮을지 몰라도, ChatGPT와 MCP Jam은 어디로 토큰을 받으러 가야 하는지, 어떤 scope가 필요한지 알 수 없습니다. 결과적으로 MCP 서버가 ‘고장난’ 것으로 간주되어 링크 UI를 보여주지 않습니다. 최소한 필요한 것은 WWW-Authenticate: Bearer resource_metadata=".../.well-known/oauth-protected-resource"입니다. scope="..."도 함께 넣으면 플로우가 더 명확해집니다. 우리의 unauthorized() helper는 401 시 이 헤더가 항상 포함되도록 보장합니다.
오류 3: 서명과 iss 검증 없이 토큰을 신뢰.
초기 단계에서 “내 Auth Server가 준 토큰이니 그냥 JSON.parse(atob(..)) 하면 되지”라는 유혹이 큽니다. 이렇게 하면 형식만 맞춘 가짜 토큰도 받아들이게 됩니다. 올바른 방식은 jwks_uri로 키를 로드하고, 라이브러리(jose, jsonwebtoken 등)를 통해 서명과 iss/aud를 검증하는 것입니다. 그 후에야 클레임 내용을 신뢰할 수 있습니다.
오류 4: 토큰 검증과 비즈니스 로직을 혼합.
어떤 도구는 scope를 검사하고, 다른 도구는 안 하고, 어떤 곳에서는 aud를 깜빡하고, 심지어 어떤 곳은 tool 인자로 온 사용자 id를 그대로 신뢰하기도 합니다. 이는 이상한 버그와 취약점으로 이어집니다. 명확히 분리하세요. HTTP 레이어의 미들웨어가 토큰(서명, iss, aud, 만료)을 책임지고, 도구에서는 ctx.user를 ‘사실’로 받아들인 뒤 역할/tenant 같은 비즈니스 검증만 보완합니다.
오류 5: scopes_supported와 실제 사용하는 scope 간의 불일치.
또 자주 있는 경우는 .well-known/oauth-protected-resource에는 한 세트의 scope를 게시하고, Auth Server에는 다른 세트를 두며, 도구에서는 세 번째 세트를 검사하는 겁니다. ChatGPT/MCP Jam은 게시된 scopes_supported를 기준으로 권한 요청을 구성하는데, 여러분의 서버는 필요한 scope가 없다며 불평합니다. scope 수를 최소화하고 단일 진실 소스(single source of truth)로 관리하세요. 예를 들어 TypeScript enum으로 정의하고, 이를 바탕으로 .well-known을 생성하며 Auth Server의 클라이언트 설정에도 동일하게 사용합니다.
오류 6: Apps SDK의 securitySchemes만 믿고 서버 측 검증을 잊음.
Apps SDK는 도구에 대한 securitySchemes(noauth, oauth2, scopes)을 기술할 수 있고, ChatGPT는 올바른 UX를 보여줍니다. 그러나 이 주석만으로 서버가 자동으로 안전해지지는 않습니다. 도구가 OAuth 토큰을 요구한다고 선언했더라도, MCP 서버는 매 요청마다 토큰, issuer, audience, scope를 검증해야 합니다. 그렇지 않으면 MCP URL로 직접 요청해 검증을 우회할 수 있습니다.
오류 7: 짧은 토큰 수명과 만료 처리 간과.
access 토큰이 너무 오래 살면 보안이 약해지고, 너무 짧은데 만료 처리를 제대로 못 하면 사용자는 계속 오류를 겪습니다. 올바른 모델은 수명이 짧은 access 토큰과, exp가 지났을 때 MCP 서버가 401과 WWW-Authenticate로 응답할 준비를 하는 것입니다. 그러면 클라이언트(ChatGPT)가 OAuth 플로우를 반복해 토큰을 갱신합니다.
GO TO FULL VERSION