본문으로 건너뛰기

MCP의 권한 부여 이해하기

Model Context Protocol(MCP)의 권한 부여는 MCP 서버가 노출하는 민감한 리소스와 작업에 대한 접근을 보호합니다. MCP 서버가 사용자 데이터나 관리 작업을 처리한다면, 권한 부여를 통해 허용된 사용자만 엔드포인트에 접근하도록 보장할 수 있습니다.

MCP는 표준화된 권한 부여 흐름을 사용하여 MCP 클라이언트와 MCP 서버 간의 신뢰를 구축합니다. 특정 권한 부여 또는 ID 시스템 하나에 초점을 맞추지 않고 OAuth 2.1에 명시된 규칙을 따르도록 설계되었습니다. 자세한 내용은 권한 부여 사양을 참조하세요.

권한 부여는 언제 사용해야 하나요?

MCP 서버의 권한 부여는 선택 사항이지만, 다음과 같은 경우에는 사용하는 것이 좋습니다.

  • 서버가 사용자별 데이터(이메일, 문서, 데이터베이스)에 접근하는 경우
  • 누가 어떤 작업을 수행했는지 감사해야 하는 경우
  • 서버가 사용자 동의가 필요한 API에 대한 접근 권한을 제공하는 경우
  • 엄격한 접근 제어가 적용되는 엔터프라이즈 환경을 구축하는 경우
  • 사용자별 요청 속도 제한 또는 사용량 추적을 구현하려는 경우

권한 부여 흐름: 단계별 설명

클라이언트가 보호된 MCP 서버에 연결하려 할 때 어떤 일이 일어나는지 단계별로 살펴보겠습니다.

초기 핸드셰이크

MCP 클라이언트가 처음 연결을 시도하면 서버는 401 Unauthorized로 응답하고, 보호된 리소스 메타데이터(PRM) 문서에 담긴 권한 부여 정보를 어디에서 찾을 수 있는지 클라이언트에 알려 줍니다. 이 문서는 MCP 서버에서 호스팅되고 예측 가능한 경로 패턴을 따르며, WWW-Authenticate 헤더의 resource_metadata 매개변수를 통해 클라이언트에 제공됩니다.

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="mcp",
resource_metadata="https://your-server.com/.well-known/oauth-protected-resource"

이를 통해 클라이언트는 MCP 서버에 권한 부여가 필요하다는 사실과 권한 부여 흐름을 시작하는 데 필요한 정보를 어디에서 가져올지 알 수 있습니다.

보호된 리소스 메타데이터 검색

PRM 문서를 가리키는 URI를 사용하여 클라이언트는 메타데이터를 가져오고 권한 부여 서버, 지원되는 범위, 기타 리소스 정보를 확인합니다. 데이터는 일반적으로 아래와 비슷한 JSON 객체에 담깁니다.

{
"resource": "https://your-server.com/mcp",
"authorization_servers": ["https://auth.your-server.com"],
"scopes_supported": ["mcp:tools", "mcp:resources"]
}

RFC 9728 섹션 3.2에서 더 포괄적인 예를 확인할 수 있습니다.

권한 부여 서버 검색

다음으로 클라이언트는 권한 부여 서버의 메타데이터를 가져와 서버가 제공하는 기능을 확인합니다. PRM 문서에 권한 부여 서버가 둘 이상 나열되어 있다면 클라이언트가 사용할 서버를 선택할 수 있습니다.

권한 부여 서버를 선택한 클라이언트는 표준 메타데이터 URI를 구성하고, 권한 부여 서버의 지원 여부에 따라 OpenID Connect(OIDC) 검색 또는 OAuth 2.0 권한 부여 서버 메타데이터 엔드포인트에 요청을 보냅니다. 그런 다음 권한 부여 흐름을 완료하는 데 필요한 엔드포인트를 알려 주는 또 다른 메타데이터 속성 집합을 가져옵니다.

{
"issuer": "https://auth.your-server.com",
"authorization_endpoint": "https://auth.your-server.com/authorize",
"token_endpoint": "https://auth.your-server.com/token",
"registration_endpoint": "https://auth.your-server.com/register"
}

클라이언트 등록

메타데이터 확인이 모두 끝나면 클라이언트는 자신이 권한 부여 서버에 등록되어 있는지 확인해야 합니다. 등록 방법은 두 가지입니다.

첫째, 클라이언트를 지정된 권한 부여 서버에 사전 등록할 수 있습니다. 이 경우 클라이언트는 권한 부여 흐름을 완료하는 데 사용할 클라이언트 등록 정보를 내장할 수 있습니다.

또는 클라이언트가 동적 클라이언트 등록(DCR)을 사용하여 권한 부여 서버에 동적으로 자신을 등록할 수 있습니다. 이 방식에서는 권한 부여 서버가 DCR을 지원해야 합니다. 권한 부여 서버가 DCR을 지원하면 클라이언트는 자신의 정보와 함께 registration_endpoint에 요청을 보냅니다.

{
"client_name": "My MCP Client",
"redirect_uris": ["http://localhost:3000/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"]
}

등록에 성공하면 권한 부여 서버는 클라이언트 등록 정보가 담긴 JSON 객체를 반환합니다.

사용자 권한 부여

이제 클라이언트는 브라우저에서 /authorize 엔드포인트를 열어 사용자가 로그인하고 필요한 권한을 부여할 수 있게 해야 합니다. 그런 다음 권한 부여 서버는 권한 부여 코드와 함께 클라이언트로 다시 리디렉션하며, 클라이언트는 이 코드를 토큰과 교환합니다.

{
"access_token": "eyJhbGciOiJSUzI1NiIs...",
"refresh_token": "def502...",
"token_type": "Bearer",
"expires_in": 3600
}

접근 토큰은 클라이언트가 MCP 서버에 보내는 요청을 인증할 때 사용합니다. 이 단계는 표준 PKCE를 사용하는 OAuth 2.1 권한 부여 코드 규칙을 따릅니다.

인증된 요청 보내기

마지막으로 클라이언트는 Authorization 헤더에 접근 토큰을 포함하여 MCP 서버에 요청을 보낼 수 있습니다.

GET /mcp HTTP/1.1
Host: your-server.com
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...

MCP 서버는 토큰을 검증하고, 토큰이 유효하며 필요한 권한을 보유한 경우 요청을 처리해야 합니다.

구현 예제

실용적인 구현을 시작하기 위해 Docker 컨테이너에서 호스팅되는 Keycloak 권한 부여 서버를 사용하겠습니다. Keycloak은 테스트와 실험을 위해 로컬에 쉽게 배포할 수 있는 오픈 소스 권한 부여 서버입니다.

Docker Desktop을 다운로드하여 설치했는지 확인하세요. 개발 머신에 Keycloak을 배포하려면 Docker Desktop이 필요합니다.

Keycloak 설정

터미널 애플리케이션에서 다음 명령어를 실행하여 Keycloak 컨테이너를 시작합니다.

docker run -p 127.0.0.1:8080:8080 -e KC_BOOTSTRAP_ADMIN_USERNAME=admin -e KC_BOOTSTRAP_ADMIN_PASSWORD=admin quay.io/keycloak/keycloak start-dev

이 명령어는 Keycloak 컨테이너 이미지를 로컬로 가져오고 기본 구성을 부트스트랩합니다. Keycloak은 8080 포트에서 실행되며 비밀번호가 adminadmin 사용자를 갖게 됩니다.

브라우저에서 http://localhost:8080으로 Keycloak 권한 부여 서버에 접근할 수 있습니다.

Keycloak 관리 대시보드 인증 대화 상자.

기본 구성으로 실행하면 Keycloak은 동적 클라이언트 등록을 비롯하여 MCP 서버에 필요한 여러 기능을 이미 지원합니다. 다음 위치의 OIDC 구성을 확인하여 이를 검증할 수 있습니다.

http://localhost:8080/realms/master/.well-known/openid-configuration

기본 정책에서는 익명 동적 클라이언트 등록을 제한하므로, 사용할 범위를 지원하고 호스트(로컬 머신)에서 클라이언트를 동적으로 등록할 수 있도록 Keycloak도 설정해야 합니다.

Keycloak 대시보드에서 Client scopes로 이동하여 새 mcp:tools 범위를 만듭니다. 이 범위를 사용하여 MCP 서버의 모든 도구에 접근할 것입니다.

Keycloak 범위 구성.

범위를 만든 후 유형을 Default로 지정하고 Include in token scope 스위치를 켰는지 확인하세요. 토큰을 검증할 때 이 설정이 필요합니다.

이제 Keycloak에서 발급한 토큰의 **대상(audience)**도 설정하겠습니다. 대상은 발급된 접근 토큰에 의도한 목적지를 직접 포함하므로 반드시 구성해야 합니다. 이를 통해 MCP 서버는 받은 토큰이 다른 API가 아니라 실제로 자신을 대상으로 발급되었는지 확인할 수 있습니다. 이는 토큰 패스스루 시나리오를 방지하는 데 핵심적인 요소입니다.

이렇게 하려면 mcp:tools 클라이언트 범위를 열고 Mappers, Configure a new mapper를 차례로 클릭한 다음 Audience를 선택합니다.

Keycloak에서 토큰 대상 구성.

Name에는 audience-config를 사용합니다. Included Custom Audience 값을 추가하고 http://localhost:3000으로 설정합니다. 이 값이 테스트 서버의 URI가 됩니다.

이제 Clients, Client registration, Trusted Hosts로 차례로 이동합니다. Client URIs Must Match 설정을 비활성화하고 테스트를 실행할 호스트를 추가합니다. Linux 또는 macOS에서는 ifconfig 명령어를, Windows에서는 ipconfig를 실행하여 현재 호스트 IP를 확인할 수 있습니다. Keycloak 로그에서 Failed to verify remote host : 192.168.215.1과 같은 줄을 찾아 추가해야 할 IP 주소를 확인할 수 있습니다. 해당 IP 주소가 호스트에 할당된 주소인지 확인하세요. Docker 설정에 따라 브리지 네트워크의 주소일 수도 있습니다.

Keycloak에서 클라이언트 등록 세부 정보 설정.

마지막으로 토큰 검사 등의 작업을 위해 MCP 서버 자체에서 Keycloak과 통신할 때 사용할 새 클라이언트를 등록해야 합니다. 등록 방법은 다음과 같습니다.

  1. Clients로 이동합니다.
  2. Create client를 클릭합니다.
  3. 클라이언트에 고유한 Client ID를 지정하고 Next를 클릭합니다.
  4. Client authentication을 활성화하고 Next를 클릭합니다.
  5. Save를 클릭합니다.

토큰 검사는 토큰을 검증하는 여러 방법 중 _하나_일 뿐입니다. 언어와 플랫폼별 독립 실행형 라이브러리를 사용해서도 검증할 수 있습니다.

클라이언트 세부 정보를 열고 Credentials로 이동하여 Client Secret을 기록해 둡니다.

Keycloak에서 새 클라이언트 생성.

Keycloak 구성이 끝나면 권한 부여 흐름이 트리거될 때마다 MCP 서버는 다음과 같은 토큰을 받습니다.

eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICI1TjcxMGw1WW5MWk13WGZ1VlJKWGtCS3ZZMzZzb3JnRG5scmlyZ2tlTHlzIn0.eyJleHAiOjE3NTU1NDA4MTcsImlhdCI6MTc1NTU0MDc1NywiYXV0aF90aW1lIjoxNzU1NTM4ODg4LCJqdGkiOiJvbnJ0YWM6YjM0MDgwZmYtODQwNC02ODY3LTgxYmUtMTIzMWI1MDU5M2E4IiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo4MDgwL3JlYWxtcy9tYXN0ZXIiLCJhdWQiOiJodHRwOi8vbG9jYWxob3N0OjMwMDAiLCJzdWIiOiIzM2VkNmM2Yi1jNmUwLTQ5MjgtYTE2MS1mMmY2OWM3YTAzYjkiLCJ0eXAiOiJCZWFyZXIiLCJhenAiOiI3OTc1YTViNi04YjU5LTRhODUtOWNiYS04ZmFlYmRhYjg5NzQiLCJzaWQiOiI4ZjdlYzI3Ni0zNThmLTRjY2MtYjMxMy1kYjA4MjkwZjM3NmYiLCJzY29wZSI6Im1jcDp0b29scyJ9.P5xCRtXORly0R0EXjyqRCUx-z3J4uAOWNAvYtLPXroykZuVCCJ-K1haiQSwbURqfsVOMbL7jiV-sD6miuPzI1tmKOkN_Yct0Vp-azvj7U5rEj7U6tvPfMkg2Uj_jrIX0KOskyU2pVvGZ-5BgqaSvwTEdsGu_V3_E0xDuSBq2uj_wmhqiyTFm5lJ1WkM3Hnxxx1_AAnTj7iOKMFZ4VCwMmk8hhSC7clnDauORc0sutxiJuYUZzxNiNPkmNeQtMCGqWdP1igcbWbrfnNXhJ6NswBOuRbh97_QraET3hl-CNmyS6C72Xc0aOwR_uJ7xVSBTD02OaQ1JA6kjCATz30kGYg

디코딩하면 다음과 같은 형태입니다.

{
"alg": "RS256",
"typ": "JWT",
"kid": "5N710l5YnLZMwXfuVRJXkBKvY36sorgDnlrirgkeLys"
}.{
"exp": 1755540817,
"iat": 1755540757,
"auth_time": 1755538888,
"jti": "onrtac:b34080ff-8404-6867-81be-1231b50593a8",
"iss": "http://localhost:8080/realms/master",
"aud": "http://localhost:3000",
"sub": "33ed6c6b-c6e0-4928-a161-f2f69c7a03b9",
"typ": "Bearer",
"azp": "7975a5b6-8b59-4a85-9cba-8faebdab8974",
"sid": "8f7ec276-358f-4ccc-b313-db08290f376f",
"scope": "mcp:tools"
}.[Signature]

MCP 서버 설정

이제 로컬에서 실행 중인 Keycloak 권한 부여 서버를 사용하도록 MCP 서버를 설정하겠습니다. 선호하는 프로그래밍 언어에 따라 지원되는 MCP SDK 중 하나를 사용할 수 있습니다.

테스트를 위해 덧셈 도구 하나와 곱셈 도구 하나, 총 두 개의 도구를 노출하는 매우 간단한 MCP 서버를 만들겠습니다. 이 도구에 접근하려면 권한 부여가 필요합니다.

전체 TypeScript 프로젝트는 샘플 저장소에서 확인할 수 있습니다.

아래 코드를 실행하기 전에 다음 내용이 포함된 .env 파일이 있는지 확인하세요.

# Server host/port
HOST=localhost
PORT=3000

# Auth server location
AUTH_HOST=localhost
AUTH_PORT=8080
AUTH_REALM=master

# Keycloak OAuth client credentials
OAUTH_CLIENT_ID=<YOUR_SERVER_CLIENT_ID>
OAUTH_CLIENT_SECRET=<YOUR_SERVER_CLIENT_SECRET>

OAUTH_CLIENT_IDOAUTH_CLIENT_SECRET은 앞에서 만든 MCP 서버 클라이언트와 연결됩니다.

아래 서버는 MCP 권한 부여 사양을 구현할 뿐만 아니라, 클라이언트에서 받은 토큰이 유효한지 확인하기 위해 Keycloak을 통한 토큰 검사도 수행합니다. 문제를 쉽게 진단할 수 있도록 기본 로깅도 구현합니다.

import "dotenv/config";
import express from "express";
import { randomUUID } from "node:crypto";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod";
import cors from "cors";
import {
mcpAuthMetadataRouter,
getOAuthProtectedResourceMetadataUrl,
} from "@modelcontextprotocol/sdk/server/auth/router.js";
import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js";
import { OAuthMetadata } from "@modelcontextprotocol/sdk/shared/auth.js";
import { checkResourceAllowed } from "@modelcontextprotocol/sdk/shared/auth-utils.js";
const CONFIG = {
host: process.env.HOST || "localhost",
port: Number(process.env.PORT) || 3000,
auth: {
host: process.env.AUTH_HOST || process.env.HOST || "localhost",
port: Number(process.env.AUTH_PORT) || 8080,
realm: process.env.AUTH_REALM || "master",
clientId: process.env.OAUTH_CLIENT_ID || "mcp-server",
clientSecret: process.env.OAUTH_CLIENT_SECRET || "",
},
};

function createOAuthUrls() {
const authBaseUrl = new URL(
`http://${CONFIG.auth.host}:${CONFIG.auth.port}/realms/${CONFIG.auth.realm}/`,
);
return {
issuer: authBaseUrl.toString(),
introspection_endpoint: new URL(
"protocol/openid-connect/token/introspect",
authBaseUrl,
).toString(),
authorization_endpoint: new URL(
"protocol/openid-connect/auth",
authBaseUrl,
).toString(),
token_endpoint: new URL(
"protocol/openid-connect/token",
authBaseUrl,
).toString(),
};
}

function createRequestLogger() {
return (req: any, res: any, next: any) => {
const start = Date.now();
res.on("finish", () => {
const ms = Date.now() - start;
console.log(
`${req.method} ${req.originalUrl} -> ${res.statusCode} ${ms}ms`,
);
});
next();
};
}

const app = express();

app.use(
express.json({
verify: (req: any, _res, buf) => {
req.rawBody = buf?.toString() ?? "";
},
}),
);

app.use(
cors({
origin: "*",
exposedHeaders: ["Mcp-Session-Id"],
}),
);

app.use(createRequestLogger());

const mcpServerUrl = new URL(`http://${CONFIG.host}:${CONFIG.port}`);
const oauthUrls = createOAuthUrls();

const oauthMetadata: OAuthMetadata = {
...oauthUrls,
response_types_supported: ["code"],
};

const tokenVerifier = {
verifyAccessToken: async (token: string) => {
const endpoint = oauthMetadata.introspection_endpoint;

if (!endpoint) {
console.error("[auth] no introspection endpoint in metadata");
throw new Error("No token verification endpoint available in metadata");
}

const params = new URLSearchParams({
token: token,
client_id: CONFIG.auth.clientId,
});

if (CONFIG.auth.clientSecret) {
params.set("client_secret", CONFIG.auth.clientSecret);
}

let response: Response;
try {
response = await fetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: params.toString(),
});
} catch (e) {
console.error("[auth] introspection fetch threw", e);
throw e;
}

if (!response.ok) {
const txt = await response.text();
console.error("[auth] introspection non-OK", { status: response.status });

try {
const obj = JSON.parse(txt);
console.log(JSON.stringify(obj, null, 2));
} catch {
console.error(txt);
}
throw new Error(`Invalid or expired token: ${txt}`);
}

let data: any;
try {
data = await response.json();
} catch (e) {
const txt = await response.text();
console.error("[auth] failed to parse introspection JSON", {
error: String(e),
body: txt,
});
throw e;
}

if (data.active === false) {
throw new Error("Inactive token");
}

if (!data.aud) {
throw new Error("Resource indicator (aud) missing");
}

const audiences: string[] = Array.isArray(data.aud) ? data.aud : [data.aud];
const allowed = audiences.some((a) => {
try {
return checkResourceAllowed({
requestedResource: a,
configuredResource: mcpServerUrl,
});
} catch {
// Keycloak tokens include non-URL audiences (e.g. "account", "test-client").
// Those are never our resource, so treat them as "no match" instead of crashing.
return false;
}
});
if (!allowed) {
throw new Error(
`None of the provided audiences are allowed. Expected ${mcpServerUrl}, got: ${audiences.join(", ")}`,
);
}

return {
token,
clientId: data.client_id,
scopes: data.scope ? data.scope.split(" ") : [],
expiresAt: data.exp,
};
},
};
app.use(
mcpAuthMetadataRouter({
oauthMetadata,
resourceServerUrl: mcpServerUrl,
scopesSupported: ["mcp:tools"],
resourceName: "MCP Demo Server",
}),
);

const authMiddleware = requireBearerAuth({
verifier: tokenVerifier,
requiredScopes: [],
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl),
});

const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {};

function createMcpServer() {
const server = new McpServer({
name: "example-server",
version: "1.0.0",
});

server.registerTool(
"add",
{
title: "Addition Tool",
description: "Add two numbers together",
inputSchema: {
a: z.number().describe("First number to add"),
b: z.number().describe("Second number to add"),
},
},
async ({ a, b }) => ({
content: [{ type: "text", text: `${a} + ${b} = ${a + b}` }],
}),
);

server.registerTool(
"multiply",
{
title: "Multiplication Tool",
description: "Multiply two numbers together",
inputSchema: {
x: z.number().describe("First number to multiply"),
y: z.number().describe("Second number to multiply"),
},
},
async ({ x, y }) => ({
content: [{ type: "text", text: `${x} × ${y} = ${x * y}` }],
}),
);

return server;
}

const mcpPostHandler = async (req: express.Request, res: express.Response) => {
const sessionId = req.headers["mcp-session-id"] as string | undefined;
let transport: StreamableHTTPServerTransport;

if (sessionId && transports[sessionId]) {
transport = transports[sessionId];
} else if (!sessionId && isInitializeRequest(req.body)) {
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (sessionId) => {
transports[sessionId] = transport;
},
});

transport.onclose = () => {
if (transport.sessionId) {
delete transports[transport.sessionId];
}
};

const server = createMcpServer();
await server.connect(transport);
} else {
res.status(400).json({
jsonrpc: "2.0",
error: {
code: -32000,
message: "Bad Request: No valid session ID provided",
},
id: null,
});
return;
}

await transport.handleRequest(req, res, req.body);
};

const handleSessionRequest = async (
req: express.Request,
res: express.Response,
) => {
const sessionId = req.headers["mcp-session-id"] as string | undefined;
if (!sessionId || !transports[sessionId]) {
res.status(400).send("Invalid or missing session ID");
return;
}

const transport = transports[sessionId];
await transport.handleRequest(req, res);
};

app.post("/", authMiddleware, mcpPostHandler);
app.get("/", authMiddleware, handleSessionRequest);
app.delete("/", authMiddleware, handleSessionRequest);

app.listen(CONFIG.port, CONFIG.host, () => {
console.log(`🚀 MCP Server running on ${mcpServerUrl.origin}`);
console.log(`📡 MCP endpoint available at ${mcpServerUrl.origin}`);
console.log(
`🔐 OAuth metadata available at ${getOAuthProtectedResourceMetadataUrl(mcpServerUrl)}`,
);
});

서버를 실행한 후 MCP 서버 엔드포인트를 제공하여 Visual Studio Code와 같은 MCP 클라이언트에 추가할 수 있습니다.

TypeScript로 MCP 서버를 구현하는 방법에 대한 자세한 내용은 TypeScript SDK 문서를 참조하세요.

MCP 서버 테스트

테스트에는 Visual Studio Code를 사용하지만, MCP와 새로운 권한 부여 사양을 지원하는 클라이언트라면 무엇이든 사용할 수 있습니다.

Cmd + Shift + P를 누르고 **MCP: Add server...**를 선택합니다. HTTP를 선택하고 http://localhost:3000을 입력합니다. Visual Studio Code에서 사용할 고유한 서버 이름을 지정합니다. 이제 mcp.json에 다음과 같은 항목이 표시됩니다.

"my-mcp-server-18676652": {
"url": "http://localhost:3000",
"type": "http"
}

연결하면 브라우저로 이동하며, Visual Studio Code에 mcp:tools 범위에 대한 접근 권한을 부여할지 묻는 동의 화면이 표시됩니다.

VS Code용 Keycloak 동의 양식.

동의하면 mcp.json의 서버 항목 바로 위에 도구 목록이 표시됩니다.

VS Code에 표시된 도구 목록.

채팅 보기에서 # 기호를 사용하여 개별 도구를 호출할 수 있습니다.

VS Code에서 MCP 도구 호출.

자주 발생하는 문제와 예방법

공격 벡터, 완화 전략, 구현 모범 사례를 포함한 종합적인 보안 지침은 보안 모범 사례를 반드시 읽어 보세요. 다음은 특히 중요한 몇 가지 문제입니다.

  • 토큰 검증이나 권한 부여 로직을 직접 구현하지 마세요. 토큰 검증과 권한 부여 결정에는 바로 적용할 수 있고 충분히 검증된 안전한 라이브러리를 사용하세요. 보안 전문가가 아니라면 모든 것을 처음부터 구현할 때 잘못 구현할 가능성이 커집니다.
  • 수명이 짧은 접근 토큰을 사용하세요. 사용하는 권한 부여 서버에 따라 이 설정을 변경할 수 있습니다. 수명이 긴 토큰은 사용하지 않는 것이 좋습니다. 악의적인 행위자가 토큰을 훔치면 더 오랫동안 접근 권한을 유지할 수 있기 때문입니다.
  • 항상 토큰을 검증하세요. 서버가 토큰을 받았다고 해서 해당 토큰이 유효하거나 서버를 대상으로 발급되었다는 의미는 아닙니다. MCP 서버가 클라이언트에서 받은 값이 필수 제약 조건과 일치하는지 항상 확인하세요.
  • 토큰을 안전하게 암호화된 저장소에 보관하세요. 일부 환경에서는 서버 측에서 토큰을 캐시해야 할 수 있습니다. 이 경우 저장소에 적절한 접근 제어가 적용되어 있고, 서버에 접근한 악의적인 주체가 토큰을 쉽게 유출할 수 없는지 확인하세요. 또한 MCP 서버가 만료되었거나 그 밖의 이유로 유효하지 않은 토큰을 재사용하지 않도록 강력한 캐시 제거 정책을 구현해야 합니다.
  • 프로덕션에서는 HTTPS를 강제하세요. 개발 중인 localhost를 제외하고 일반 HTTP를 통한 토큰이나 리디렉션 콜백을 허용하지 마세요.
  • 최소 권한 범위를 사용하세요. 모든 권한을 포괄하는 범위를 사용하지 마세요. 가능한 경우 도구 또는 기능별로 접근 권한을 나누고 리소스 서버에서 경로나 도구별로 필요한 범위를 확인하세요.
  • 자격 증명을 로그에 기록하지 마세요. Authorization 헤더, 토큰, 코드 또는 시크릿을 절대 기록하지 마세요. 쿼리 문자열과 헤더에서 민감한 정보를 제거하고 구조화된 로그의 민감한 필드를 가리세요.
  • 애플리케이션과 리소스 서버의 자격 증명을 분리하세요. 최종 사용자 흐름에 MCP 서버의 클라이언트 시크릿을 재사용하지 마세요. 모든 시크릿은 버전 관리 시스템이 아니라 적절한 시크릿 관리자에 저장하세요.
  • 올바른 인증 챌린지를 반환하세요. 401 응답에는 Bearer, realm, resource_metadata가 포함된 WWW-Authenticate를 넣어 클라이언트가 인증 방법을 검색할 수 있게 하세요.
  • DCR(동적 클라이언트 등록)을 제어하세요. DCR을 활성화했다면 신뢰할 수 있는 호스트, 필수 검토, 등록 감사 등 조직별 제약 조건에 유의하세요. 인증되지 않은 DCR을 허용하면 누구나 권한 부여 서버에 어떤 클라이언트든 등록할 수 있습니다.
  • 다중 테넌트/realm 혼동을 방지하세요. 명시적으로 다중 테넌트를 지원하지 않는 한 단일 발급자와 테넌트로 고정하세요. 같은 권한 부여 서버에서 서명한 토큰이라도 다른 realm의 토큰은 거부하세요.
  • 대상/리소스 지시자를 오용하지 마세요. api 같은 일반적인 대상이나 관련 없는 리소스를 구성하거나 허용하지 마세요. 대상 또는 리소스가 구성된 서버와 일치하도록 요구하세요.
  • 오류 세부 정보가 유출되지 않게 하세요. 클라이언트에는 일반적인 메시지를 반환하되, 내부 정보를 노출하지 않고도 문제를 해결할 수 있도록 내부 로그에는 상관관계 ID와 함께 자세한 원인을 기록하세요.
  • 세션 식별자를 강화하세요. Mcp-Session-Id를 신뢰할 수 없는 입력으로 취급하고 권한 부여와 절대 연결하지 마세요. 인증이 변경되면 다시 생성하고 서버 측에서 수명 주기를 검증하세요.

MCP 권한 부여는 다음과 같이 잘 정립된 표준을 기반으로 합니다.

  • OAuth 2.1: 핵심 권한 부여 프레임워크
  • RFC 8414: 권한 부여 서버 메타데이터 검색
  • RFC 7591: 동적 클라이언트 등록
  • RFC 9728: 보호된 리소스 메타데이터
  • RFC 8707: 리소스 지시자

자세한 내용은 다음 문서를 참조하세요.

이러한 표준을 이해하면 권한 부여를 올바르게 구현하고 문제가 발생했을 때 해결하는 데 도움이 됩니다.