OAuth 클라이언트 자격 증명

OAuth 클라이언트 자격 증명 (OAuth Client Credentials)

출처: MCP 공식 문서 — OAuth Client Credentials

OAuth Client Credentials 익스텐션(io.modelcontextprotocol/oauth-client-credentials)은 OAuth 2.0 client credentials 흐름을 MCP에 더해요. 이로써 자동화 시스템이 대화형 사용자 인가 없이 MCP 서버에 연결할 수 있게 돼요.

무엇인가

표준 MCP 인가 흐름은 사용자가 대화형으로 접근을 승인해야 해요. 브라우저가 열리고, 로그인하고, 권한을 부여하죠. 사람에게는 좋지만 사용자가 없으면 무너져요.

OAuth Client Credentials 익스텐션은 클라이언트가 위임된 사용자 자격 증명 대신 애플리케이션 수준 자격 증명(클라이언트 ID와 시크릿, 또는 서명된 JWT assertion)으로 인증하게 해서 이 문제를 풀어요. 클라이언트가 인가 서버에 직접 자신의 신원을 증명하면, 브라우저 리다이렉트나 사용자 상호작용 없이 접근 토큰을 발급받아요.

언제 쓸까

다음 때 OAuth Client Credentials를 쓰세요.

  • 백그라운드 서비스가 사용자 없이 스케줄이나 이벤트에 따라 MCP 도구를 호출해야 할 때
  • CI/CD 파이프라인이 자동화된 빌드·테스트·배포 워크플로의 일부로 MCP 서버를 호출할 때
  • 서버 간 통합이 종단 사용자가 없는 두 백엔드 시스템을 연결할 때
  • 데몬 프로세스나 장수 실행 워커가 MCP 리소스에 지속적으로 접근해야 할 때

통합에 접근을 명시적으로 인가해야 하는 사람 사용자가 있다면 표준 MCP 인가 흐름을 대신 쓰세요.

어떻게 동작하는가

익스텐션은 두 가지 자격 증명 형식을 지원해요.

JWT Bearer Assertion (권장)

RFC 7523에 정의된 JWT Bearer Assertion은 클라이언트가 자기 비공개 키로 토큰을 서명해 신원 증명으로 제시하게 해요. 인가 서버는 클라이언트의 등록된 공개 키로 서명을 검증해요.

sequenceDiagram
    participant Client
    participant AS as Authorization Server
    participant MCP as MCP Server

    Client->>AS: POST /token<br/>grant_type=urn:ietf:params:<br/>oauth:grant-type:jwt-bearer<br/>assertion=<signed JWT>
    AS-->>Client: access_token
    Client->>MCP: MCP request (Bearer token)

JWT assertion은 보통 다음을 포함해요.

  • iss: Client ID (발행자)
  • sub: Client ID (인증되는 주체)
  • aud: 인가 서버 토큰 엔드포인트 URL
  • exp: 만료 시각
  • iat: 발행 시각

클라이언트 시크릿

더 단순한 배포를 위해 익스텐션은 client_idclient_secret을 쓰는 표준 client credentials 흐름도 지원해요. 클라이언트는 자격 증명을 인가 서버의 토큰 엔드포인트로 직접 보내고 접근 토큰을 받아요.

sequenceDiagram
    participant Client
    participant AS as Authorization Server
    participant MCP as MCP Server

    Client->>AS: POST /token<br/>grant_type=client_credentials<br/>client_id + client_secret
    AS-->>Client: access_token
    Client->>MCP: MCP request (Bearer token)

클라이언트 시크릿은 장수 자격 증명으로, 사용자 상호작용 없이 접근을 부여해요. 시크릿이 유출되면 공격자가 시크릿이 회전될 때까지 여러분의 애플리케이션으로 조용히 인증할 수 있어요. 위험을 줄이려면:

  • 시크릿을 시크릿 관리자에 저장하고, 소스 코드나 버전 관리에 커밋된 환경 파일에는 절대 두지 마세요.
  • 정기적으로 회전하고, 손상 의심 직후에는 즉시 회전하세요.
  • 자격 증명을 요구되는 최소 권한으로 한정하세요.
  • 가능하면 JWT assertion을 선호하세요. 단수명이고 서명 키를 전송하지 않아요.

구현 가이드

MCP 클라이언트용

OAuth Client Credentials 익스텐션을 쓰려면 클라이언트는:

  1. 지원 선언 — 요청별 capabilities에 익스텐션 포함:
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "...",
  "params": {
    // Other fields...
    "_meta": {
      // Other fields...
      "io.modelcontextprotocol/clientCapabilities": {
        "extensions": {
          "io.modelcontextprotocol/oauth-client-credentials": {},
        },
      },
    },
  },
}
  1. 접근 토큰 획득 — MCP 서버에 연결하기 전에 client credentials 그랜트로 인가 서버에 토큰 요청.

  2. 토큰 포함 — MCP 서버로의 HTTP 요청 Authorization 헤더에 토큰 전달:

Authorization: Bearer ***
  1. 토큰 갱신 처리 — client credentials 토큰은 보통 사용자 위임 토큰보다 수명이 짧아요. 만료 전에 새 토큰을 얻도록 갱신 로직을 구현하세요.

MCP 서버용

client credentials 토큰을 받아들이려면 서버는:

  1. 토큰 검증 — 각 요청에서 JWT 서명과 클레임을 인가 서버의 공개 키(보통 JWKS 엔드포인트로)에 대해 검증.

  2. 스코프 확인 — 토큰이 요청된 작업에 필요한 스코프를 포함하는지 확인.

  3. 지원 광고 — 선택적(하지만 발견성을 위해 권장)으로 server/discover 응답에 익스텐션 포함:

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    // Other fields...
    "capabilities": {
      "extensions": {
        "io.modelcontextprotocol/oauth-client-credentials": {},
      },
    },
  },
}

SDK 예제

공식 MCP SDK는 client credentials 인증을 기본 내장 지원해요. 둘 다 토큰 획득과 갱신을 자동으로 처리해요.

  1. SDK 설치
# TypeScript
npm install @modelcontextprotocol/client
# Python
pip install mcp
  1. 프로바이더를 만들고 연결 — 설정에 맞는 자격 증명 형식을 고르세요.

클라이언트 시크릿 사용

import {
  Client,
  ClientCredentialsProvider,
  StreamableHTTPClientTransport,
} from "@modelcontextprotocol/client";

const provider = new ClientCredentialsProvider({
  clientId: "my-service",
  clientSecret: "s3cr3t",
});

const client = new Client(
  { name: "my-service", version: "1.0.0" },
  { capabilities: {} },
);

const transport = new StreamableHTTPClientTransport(
  new URL("https://mcp.example.com/mcp"),
  { authProvider: provider },
);

await client.connect(transport);

// Use the client
const tools = await client.listTools();
console.log(
  "Available tools:",
  tools.tools.map((t) => t.name),
);

await transport.close();
import asyncio

import httpx2

from mcp import Client
from mcp.client.auth.extensions.client_credentials import (
    ClientCredentialsOAuthProvider,
)
from mcp.client.streamable_http import streamable_http_client
from mcp.shared.auth import OAuthClientInformationFull, OAuthToken


class InMemoryTokenStorage:
    def __init__(self) -> None:
        self.tokens: OAuthToken | None = None
        self.client_info: OAuthClientInformationFull | None = None

    async def get_tokens(self) -> OAuthToken | None:
        return self.tokens

    async def set_tokens(self, tokens: OAuthToken) -> None:
        self.tokens = tokens

    async def get_client_info(self) -> OAuthClientInformationFull | None:
        return self.client_info

    async def set_client_info(self, client_info: OAuthClientInformationFull) -> None:
        self.client_info = client_info


provider = ClientCredentialsOAuthProvider(
    server_url="https://mcp.example.com/mcp",
    storage=InMemoryTokenStorage(),
    client_id="my-service",
    client_secret="s3cr3t",
    scopes="read write",
)


async def main() -> None:
    async with httpx2.AsyncClient(auth=provider) as http_client:
        transport = streamable_http_client(
            "https://mcp.example.com/mcp",
            http_client=http_client,
        )
        async with Client(transport) as client:
            # Use the client
            tools = await client.list_tools()
            print("Available tools:", [t.name for t in tools.tools])


if __name__ == "__main__":
    asyncio.run(main())

JWT 비공개 키 사용

import {
  Client,
  PrivateKey***,
  StreamableHTTPClientTransport,
} from "@modelcontextprotocol/client";

const provider = new PrivateK***({
  clientId: "my-service",
  privateKey: process.env.CLIENT_PRIVATE_KEY_PEM,
  algorithm: "RS256",
});

const client = new Client(
  { name: "my-service", version: "1.0.0" },
  { capabilities: {} },
);

const transport = new StreamableHTTPClientTransport(
  new URL("https://mcp.example.com/mcp"),
  { authProvider: provider },
);

await client.connect(transport);

// Use the client
const tools = await client.listTools();
console.log(
  "Available tools:",
  tools.tools.map((t) => t.name),
);

await transport.close();
import asyncio
from pathlib import Path

import httpx2

from mcp import Client
from mcp.client.auth.extensions.client_credentials import (
    PrivateKeyJWTO...ider,
    SignedJWTParameters,
)
from mcp.client.streamable_http import streamable_http_client
from mcp.shared.auth import OAuthClientInformationFull, OAuthToken


class InMemoryTokenStorage:
    def __init__(self) -> None:
        self.tokens: OAuthToken | None = None
        self.client_info: OAuthClientInformationFull | None = None

    async def get_tokens(self) -> OAuthToken | None:
        return self.tokens

    async def set_tokens(self, tokens: OAuthToken) -> None:
        self.tokens = tokens

    async def get_client_info(self) -> OAuthClientInformationFull | None:
        return self.client_info

    async def set_client_info(self, client_info: OAuthClientInformationFull) -> None:
        self.client_info = client_info


# Create a signed JWT assertion provider from key parameters
jwt_params = SignedJWTParameters(
    issuer="my-service",
    subject="my-service",
    signing_key=Path("private_key.pem").read_text(),
    signing_algorithm="RS256",
    lifetime_seconds=300,
)

provider = PrivateKeyJWTO...ider(
    server_url="https://mcp.example.com/mcp",
    storage=InMemoryTokenStorage(),
    client_id="my-service",
    assertion_provider=jwt_params.create_assertion_provider(),
    scopes="read write",
)


async def main() -> None:
    async with httpx2.AsyncClient(auth=provider) as http_client:
        transport = streamable_http_client(
            "https://mcp.example.com/mcp",
            http_client=http_client,
        )
        async with Client(transport) as client:
            # Use the client
            tools = await client.list_tools()
            print("Available tools:", [t.name for t in tools.tools])


if __name__ == "__main__":
    asyncio.run(main())

클라이언트 지원

이 익스텐션의 지원은 클라이언트마다 달라요. 익스텐션은 옵트인이며 기본으로 활성화되지 않아요.

MCP 클라이언트 전반의 현재 구현 상태는 client matrix에서 확인하세요.

관련 리소스

더 알아보기 (Learn more)