CIMD: MCP가 권장하는 OAuth 클라이언트 등록 방식
에이전트가 처음 보는 MCP 서버에 등록 없이 붙을 수 있게 해 주는 CIMD의 동작 원리를 알아봅니다.
에이전트에 MCP 서버를 연결하면서 OAuth 인가를 받는 것은 특별한 일이 아닙니다. 그런데 혹시 생각해 보셨나요? 에이전트는 어떻게 인가를 받는 걸까요?
사실 에이전트도 일반 앱과 똑같이 OAuth 클라이언트로서 인가를 받습니다. 사용자의 허락을 받아 Access Token을 발급받고, 그 토큰으로 리소스 서버인 MCP 서버에 데이터를 요청합니다.
그런데 OAuth 클라이언트는 인가 서버에 미리 등록해 Client ID를 받아 두어야 하고, 에이전트에게는 이 등록이 걸림돌이 됩니다. MCP 서버를 만들어 공개하는 쪽은 어떤 에이전트가 붙을지 알 수 없으므로 붙을 수 있는 모든 에이전트에 Client ID를 미리 발급해 둘 수 없고, 에이전트 쪽도 사용자가 연결할 수많은 MCP 서버마다 Client ID를 받아 둘 수는 없는 노릇입니다.
이처럼 에이전트가 OAuth를 쓰려면, 서로 처음 보는 클라이언트와 인가 서버 사이에서 Client ID를 어떻게 정할지가 문제가 됩니다. 이 글에서는 이 문제를 푸는 최신 방법인 CIMD(Client ID Metadata Document)를 다룹니다. 그 전에 MCP가 먼저 택했다가 deprecated 되었던 DCR부터 살펴보겠습니다.
DCR과 그 한계
MCP가 이 문제에 먼저 내놓은 답은 DCR(Dynamic Client Registration)입니다. DCR은 RFC 7591로 정의된 OAuth 확장으로, 클라이언트가 인가 서버의 등록 엔드포인트에 자기 정보를 보내 그 자리에서 Client ID를 발급받는 방식입니다. 개발자가 콘솔에서 하던 등록을 클라이언트가 HTTP 요청 하나로 대신한다고 보면 됩니다.
이 글의 예시 클라이언트로는 CIMD 문서를 실제로 공개하고 있는 Claude Code를 씁니다. Claude Code가 DCR을 쓰는 인가 서버에 붙으면 다음과 같이 등록을 요청합니다.
POST /register HTTP/1.1
Host: auth.example.com
Content-Type: application/json
{
"client_name": "Claude Code",
"redirect_uris": ["http://localhost/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none"
}
인가 서버는 받은 정보를 저장하고 새 Client ID를 발급해 돌려줍니다.
HTTP/1.1 201 Created
Content-Type: application/json
{
"client_id": "s6BhdRkqt3",
"client_id_issued_at": 1792222800,
"client_name": "Claude Code",
"redirect_uris": ["http://localhost/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none"
}
이후 client_id=s6BhdRkqt3로 들어오는 인가 요청은 이 등록 정보로 처리합니다. 동의 화면에는 client_name을 보여 주고, 인가 코드는 redirect_uris에 있는 주소로만 보냅니다.
이 방식에는 두 가지 한계가 있습니다.
등록 정보가 계속 쌓입니다. DCR에서는 등록할 때마다 새 Client ID가 발급되므로, 같은 클라이언트라도 등록 정보가 계속 쌓입니다. 인가 서버는 이 정보를 저장해야 하고, 더는 쓰이지 않는 등록을 언제 지울지도 직접 정해야 합니다.
등록한 쪽이 누구인지 보증할 수 없습니다. /register는 누구나 호출할 수 있고, 인가 서버는 요청을 보낸 쪽이 진짜 Claude Code인지 확인할 방법이 없습니다. 공격자가 같은 요청에서 redirect_uris만 자기 서버로 바꿔 보내도 인가 서버는 정상적으로 등록하고 Client ID를 내줍니다.
{
"client_name": "Claude Code",
"redirect_uris": ["https://attacker.example/callback"],
"token_endpoint_auth_method": "none"
}
공격자가 이 Client ID로 인가 요청 링크를 만들어 사용자에게 보내면, 사용자는 동의 화면에서 "Claude Code"라는 이름을 보고 동의하고, 인가 코드는 공격자의 서버로 전달됩니다. 인가 요청을 시작한 것도 공격자라서 PKCE의 code_verifier도 공격자가 갖고 있으므로, 공격자는 이 인가 코드를 Access Token으로 바꿀 수 있습니다.
이런 이유로 최신 MCP 인가 스펙은 DCR을 deprecated로 표시하고, CIMD를 지원하지 않는 인가 서버와의 호환을 위해서만 남겨 두었습니다.
CIMD란?

CIMD는 클라이언트가 자기 정보를 담은 JSON 문서를 HTTPS URL에 올려 두고, 그 URL을 Client ID로 쓰는 방식입니다. Claude Code의 Client ID는 https://claude.ai/oauth/claude-code-client-metadata이고, 이 주소에는 Claude Code의 이름과 redirect_uris를 담은 문서가 있습니다. CIMD는 이 구조로 DCR의 두 한계를 해결합니다.
| DCR | CIMD | |
|---|---|---|
| Client ID | 인가 서버가 발급한 임의의 값 | 클라이언트가 메타데이터 문서를 올린 URL |
| 클라이언트 정보가 있는 곳 | 인가 서버의 저장소 | Client ID URL에 있는 메타데이터 문서 |
| 클라이언트 정보를 정하는 쪽 | /register를 호출한 누구나 | Client ID URL의 사이트를 운영하는 쪽 |
인가 서버는 등록을 받지 않고 Client ID URL에서 문서를 가져와 읽기만 하므로 저장해 둘 등록 정보가 없고, Claude Code를 쓰는 사용자가 몇 명이든 Client ID는 이 URL 하나입니다.
누가 등록했는지 보증할 수 없다는 DCR의 문제도 풀립니다. CIMD에서는 클라이언트 정보가 Client ID URL의 문서에 담기고 그 문서는 사이트 운영자만 고칠 수 있으므로, Claude Code의 client_name과 redirect_uris는 claude.ai를 운영하는 Anthropic만 정할 수 있습니다. 인가 서버는 인가 코드를 이 문서에 적힌 redirect_uris로만 보냅니다. 따라서 공격자가 Claude Code의 Client ID를 가져다 써도 인가 코드를 자기 서버로 받을 수 없습니다.
MCP 인가 스펙도 MCP 클라이언트와 인가 서버가 CIMD를 지원하도록 권장합니다.
CIMD 인가 흐름
Claude Code가 CIMD로 인가 서버에 붙는 흐름입니다. 색칠된 구간이 OAuth 인가 코드 흐름과 달라지는 부분입니다.
sequenceDiagram
participant CC as Claude Code
participant B as 브라우저
participant AS as 인가 서버
participant M as claude.ai<br/>(메타데이터 문서)
rect rgba(255, 190, 0, 0.18)
CC->>AS: 0. GET /.well-known/oauth-authorization-server
AS-->>CC: [추가] client_id_metadata_document_supported: true
end
Note over CC: state, code_verifier 생성<br/>localhost:3118에서 콜백 대기
CC->>B: 1. 브라우저로 인가 URL 열기
rect rgba(255, 190, 0, 0.18)
B->>AS: 2. GET /authorize?[추가] client_id=https://claude.ai/oauth/claude-code-client-metadata<br/>&redirect_uri=http://localhost:3118/callback&state&code_challenge
AS->>M: 3. [추가] GET /oauth/claude-code-client-metadata
M-->>AS: 4. [추가] 200 OK, 메타데이터 문서(JSON)
Note over AS: 5. [추가] 문서 검증<br/>client_id 일치, redirect_uri 포함 여부
AS-->>B: 6. 로그인·동의 화면<br/>[추가] client_name과 Client ID 호스트(claude.ai) 표시
end
AS-->>B: 7. 동의 후 302 Redirect<br/>Location: http://localhost:3118/callback?code&state
B->>CC: 8. GET /callback?code&state
CC->>AS: 9. POST /token<br/>code, code_verifier, client_id(URL)
AS-->>CC: 10. access_token
0. 인가 서버의 CIMD 지원 확인
Claude Code는 MCP 서버가 알려 준 인가 서버의 메타데이터 문서(RFC 8414)를 읽어, 이 인가 서버가 CIMD를 지원하는지 확인합니다.
{
"issuer": "https://auth.example.com",
"authorization_endpoint": "https://auth.example.com/authorize",
"token_endpoint": "https://auth.example.com/token",
"code_challenge_methods_supported": ["S256"],
"token_endpoint_auth_methods_supported": ["none"],
"client_id_metadata_document_supported": true
}
client_id_metadata_document_supported: true는 "URL 형태의 Client ID가 오면 그 주소에서 메타데이터 문서를 가져와 읽겠다"는 인가 서버의 표시입니다. Claude Code는 이 값을 보고 Client ID 자리에 자기 문서의 URL을 넣어 인가 요청을 보냅니다.
함께 있는 token_endpoint_auth_methods_supported: ["none"]은 토큰을 요청할 때 클라이언트 시크릿 없이 PKCE만으로 인가 코드를 교환할 수 있다는 뜻입니다. Claude Code는 사용자 컴퓨터에서 실행되는 프로그램이라 시크릿을 안전하게 숨겨 둘 곳이 없어서 이 방식을 씁니다.
1 ~ 2. Client ID URL로 인가 요청 보내기
Claude Code는 state와 code_verifier를 만들고, 인가 코드를 받을 로컬 포트에서 콜백을 기다린 뒤 브라우저로 인가 URL을 엽니다(1). 브라우저는 인가 서버의 /authorize로 요청을 보냅니다(2).
GET https://auth.example.com/authorize
?response_type=code
&client_id=https://claude.ai/oauth/claude-code-client-metadata
&redirect_uri=http://localhost:3118/callback
&state=af0ifjsldkj
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256
OAuth 인가 요청과 형태는 같고, client_id 자리에 인가 서버가 발급한 값 대신 메타데이터 문서의 URL이 들어갑니다.
3 ~ 4. 메타데이터 문서 조회
인가 서버는 client_id가 HTTPS URL인 것을 보고 그 주소로 문서를 요청합니다(3). Claude Code의 문서에는 다음 값이 들어 있습니다(4).
{
"client_id": "https://claude.ai/oauth/claude-code-client-metadata",
"client_name": "Claude Code",
"client_uri": "https://claude.ai",
"redirect_uris": [
"http://localhost/callback",
"http://127.0.0.1/callback"
],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none"
}
| 필드 | 의미 |
|---|---|
client_id | 이 문서의 URL. 인가 요청의 client_id와 정확히 같아야 합니다. |
client_name | 동의 화면에 표시할 클라이언트 이름 |
client_uri | 클라이언트 소개 페이지 |
redirect_uris | 인가 코드를 받을 수 있는 주소 목록 |
token_endpoint_auth_method | 토큰 요청 때 클라이언트를 인증하는 방식. none은 시크릿 없이 PKCE만 씁니다. |
5. 문서 검증
인가 서버는 가져온 문서를 바로 쓰지 않고 다음을 확인합니다.
client_id일치: 문서의client_id가 조회한 URL과 정확히 같아야 합니다. 다른 클라이언트의 문서를 복사해 올려 둔 경우를 여기서 걸러 냅니다.redirect_uri포함 여부: 인가 요청의redirect_uri가 문서의redirect_uris에 있어야 합니다. 다만localhost주소는 포트가 달라도 허용합니다.- 시크릿 금지: 문서는 누구나 읽을 수 있는 URL에 있으므로, 문서에
client_secret이 있거나client_secret_basic같은 시크릿 기반 인증 방식을 쓰면 거부합니다.
6. 로그인·동의 화면
검증을 통과하면 인가 서버는 로그인·동의 화면을 보여 줍니다. 화면에는 문서의 client_name("Claude Code")과 함께 Client ID URL의 호스트(claude.ai)를 표시합니다.
7 ~ 10. 인가 코드와 토큰 교환
이후는 OAuth 인가 코드 흐름과 같습니다. 사용자가 동의하면 인가 서버는 브라우저를 redirect_uri로 돌려보내고(7), 콜백을 기다리던 Claude Code가 인가 코드를 받습니다(8). Claude Code는 인가 코드와 code_verifier로 토큰을 요청하고(9), 이때 client_id에도 같은 URL을 넣습니다.
POST /token HTTP/1.1
Host: auth.example.com
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=SplxlOBeZQQYbYS6WxSbIA
&redirect_uri=http://localhost:3118/callback
&client_id=https://claude.ai/oauth/claude-code-client-metadata
&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
인가 서버는 이 client_id가 인가 코드를 발급할 때의 client_id와 같은지 확인하고 Access Token을 발급합니다(10).
코드 예시
클라이언트: 메타데이터 문서 올리기
직접 만든 에이전트가 CIMD를 쓰려면 메타데이터 문서를 HTTPS URL에 올려 두면 됩니다. 아래는 Express로 https://agent.example.com/oauth/client-metadata에 문서를 올리는 예시입니다.
import express from 'express';
const app = express();
const CLIENT_ID = 'https://agent.example.com/oauth/client-metadata';
app.get('/oauth/client-metadata', (req, res) => {
res.set('Cache-Control', 'public, max-age=3600');
res.json({
client_id: CLIENT_ID,
client_name: 'My Agent',
client_uri: 'https://agent.example.com',
redirect_uris: ['http://localhost/callback', 'http://127.0.0.1/callback'],
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],
token_endpoint_auth_method: 'none',
});
});
문서는 Client ID에 적은 주소에서 리다이렉트 없이 바로 200 OK로 응답해야 합니다.
응답의 Cache-Control은 인가 서버가 이 문서를 얼마나 오래 캐시해 둘지 정하는 기준이 되므로, 문서를 고치면 그 시간이 지난 뒤에야 인가 서버에 반영될 수 있습니다.
인가 서버: 문서 조회와 검증
인가 서버 쪽에서는 Client ID URL에서 문서를 가져와 검증하고, 인가 요청의 redirect_uri를 문서의 목록과 비교합니다. 아래 코드는 Node.js 18 이상의 내장 fetch를 사용합니다.
export async function fetchClientMetadata(clientId: string) {
// Client ID는 https URL이어야 한다
const url = new URL(clientId);
if (url.protocol !== 'https:') throw new Error('https URL이 아닙니다');
// 200이 아니면 실패로 처리
const res = await fetch(url, { redirect: 'manual' });
if (res.status !== 200) throw new Error(`문서 조회 실패: ${res.status}`);
const metadata = await res.json();
// 문서의 client_id는 조회한 URL과 정확히 같아야 한다
if (metadata.client_id !== clientId) throw new Error('client_id 불일치');
if (!Array.isArray(metadata.redirect_uris)) throw new Error('redirect_uris 누락');
// 문서에는 시크릿을 둘 수 없다
if (metadata.client_secret || metadata.token_endpoint_auth_method?.startsWith('client_secret')) {
throw new Error('시크릿 기반 인증은 쓸 수 없습니다');
}
return metadata;
}
redirect_uri는 문서의 redirect_uris에 같은 값이 있는지 확인합니다. 다만 Claude Code는 실행할 때마다 빈 포트를 골라 쓰기 때문에, 문서에는 http://localhost/callback처럼 포트 없이 적어 두고 인가 요청에는 http://localhost:3118/callback처럼 포트를 붙여 보냅니다. 두 값은 문자열로는 다르므로, localhost 주소는 포트를 빼고 비교합니다.
const LOCALHOST_NAMES = ['localhost', '127.0.0.1', '[::1]'];
// 인가 요청의 redirect_uri가 문서의 redirect_uris에 있는지 확인
export function isAllowedRedirectUri(requested: string, registered: string[]): boolean {
if (registered.includes(requested)) return true;
// localhost 주소는 포트를 빼고 비교한다
const req = new URL(requested);
if (req.protocol !== 'http:' || !LOCALHOST_NAMES.includes(req.hostname)) return false;
return registered.some((uri) => {
const reg = new URL(uri);
return reg.protocol === req.protocol
&& reg.hostname === req.hostname
&& reg.pathname === req.pathname;
});
}
/authorize에서는 문서를 검증하고, redirect_uri를 확인한 뒤, 로그인·동의 화면을 보여 줍니다.
app.get('/authorize', async (req, res) => {
const clientId = String(req.query.client_id);
const redirectUri = String(req.query.redirect_uri);
let metadata;
try {
// 1. 문서 검증
metadata = await fetchClientMetadata(clientId);
} catch (e) {
// 문서를 확인할 수 없으면 에러를 보여 준다
return res.status(400).send(`invalid_client: ${(e as Error).message}`);
}
// 2. redirect_uri 확인
if (!isAllowedRedirectUri(redirectUri, metadata.redirect_uris)) {
return res.status(400).send('invalid_request: 등록되지 않은 redirect_uri');
}
// 로그인·동의 화면에 클라이언트 이름과 Client ID의 호스트를 함께 보여 준다
res.render('consent', {
clientName: metadata.client_name,
clientHost: new URL(clientId).host,
redirectHost: new URL(redirectUri).host,
});
});
CIMD의 한계
localhost redirect_uri

Claude Code의 redirect_uris는 localhost와 127.0.0.1뿐입니다. Claude Code는 사용자 컴퓨터에서 실행되는 프로그램이라, 인가 코드를 받을 곳도 사용자 컴퓨터일 수밖에 없기 때문입니다.
문제는 사용자 컴퓨터에 악성 프로그램이 깔려 있는 경우입니다. 이 프로그램도 Claude Code처럼 localhost에 콜백을 받을 포트를 열어 두고, Claude Code의 Client ID로 인가 요청을 보낼 수 있습니다. 인가 서버 입장에서 이 요청은 진짜 Claude Code의 요청과 다르지 않습니다. Client ID가 같고 redirect_uri도 문서에 적힌 localhost 주소라서 검증을 통과하고, 동의 화면에도 Claude Code와 claude.ai가 그대로 표시됩니다. 사용자가 동의하면 인가 코드는 악성 프로그램이 열어 둔 포트로 가고, 요청을 시작한 것도 그 프로그램이므로 Access Token까지 받아 갈 수 있습니다.
CIMD는 문서를 올린 사이트로 클라이언트를 구분하는데, localhost는 그 컴퓨터에서 실행되는 모든 프로그램이 함께 쓰는 주소라서 이 경우에는 진짜와 가짜를 구분할 방법이 없습니다. MCP 인가 스펙도 CIMD만으로는 이 사칭을 막을 수 없다고 밝히고, 인가 서버가 동의 화면에 redirect_uri의 호스트를 분명하게 보여 주고 redirect_uris가 localhost뿐인 클라이언트에는 경고를 띄우도록 요구합니다.
요약
CIMD 인가 흐름은 다음 순서로 진행됩니다.
- 클라이언트는 메타데이터 문서를 HTTPS URL에 올리고, 그 URL을 Client ID로 씁니다.
- 인가 서버는 메타데이터에
client_id_metadata_document_supported: true를 넣어 CIMD 지원을 알립니다. - 인가 요청을 받은 인가 서버는 Client ID URL에서 문서를 가져와
client_id일치,redirect_uri포함 여부, 인증 방식을 검증합니다. - 동의 화면에
client_name과 Client ID의 호스트를 보여 주고, 이후는 OAuth 인가 코드 흐름대로 진행합니다.
마치며
이 흐름을 알면 MCP 게이트웨이를 개발할 때, 게이트웨이 자체를 인가 서버로써 활용할 수 있게 됩니다. 처음 보면 헷갈리는 개념이지만 생각보다 체계적으로 흐름이 잡혀있으므로 한번 이해하면 끝까지 가져갈 수 있는 지식이 됩니다. 읽으시는 분들께 약간의 도움이 되었으면 좋겠습니다. 감사합니다.
참고 자료
- OAuth Client ID Metadata Document (draft-ietf-oauth-client-id-metadata-document-02): Client ID URL 규칙, 문서 필수 항목과 금지 항목,
client_id_metadata_document_supported, 문서 조회와 검증, 동의 화면의 호스트 표시 - RFC 7591: OAuth 2.0 Dynamic Client Registration Protocol: DCR 등록 요청·응답, 클라이언트 메타데이터 필드
- RFC 8414: OAuth 2.0 Authorization Server Metadata: 인가 서버 메타데이터 문서
- MCP Authorization (2026-07-28): MCP 클라이언트·인가 서버의 CIMD 지원 권장, DCR deprecated
- MCP Client Registration (2026-07-28): MCP에서의 CIMD 요구 사항, 등록 방식 우선순위
- MCP Authorization Security Considerations (2026-07-28): localhost redirect URI의 사칭 위험과 인가 서버 요구 사항
- Authentication for connectors (Claude 문서): Claude Code의 CIMD URL, Claude가 CIMD를 선택하는 조건, loopback 리다이렉트 포트 처리
- Claude Code Client ID Metadata Document: 예시에 사용한 실제 메타데이터 문서
댓글
아직 댓글이 없습니다.