블로그기술

원격 MCP 서버의 OAuth, 출시하며 배운 것

Supabase Auth를 인가 서버로 쓰는 운영 중인 MCP 서버에서 얻은 기록이에요. 명세가 요구하는 것, 저희가 틀린 것, 아직 테스트하지 못한 것을 정리했어요.

글쓴이
게시일
업데이트
분량
12분 분량

짧은 답

원격 MCP 서버는 OAuth로 보호되는 리소스예요. 토큰이 없으면 401로 답하며 RFC 9728 메타데이터를 가리키고, 클라이언트는 그 안내를 따라 인가 서버로 가서 등록한 뒤, PKCE와 resource 파라미터로 사용자를 로그인시켜 서버 URL을 대상으로 발급된 베어러 토큰을 들고 돌아와요. 저희는 이 구조를 Supabase Auth로 운영 환경에서 돌려요. 손이 많이 간 세부 사항은 세 가지예요. 스코프 힌트는 있고 오류 코드는 없는 401, Supabase 토큰에 저희 오디언스와 스코프를 넣는 토큰 훅, 그리고 저희 인가 서버가 클라이언트 ID 메타데이터 문서를 제공하지 않아서 쓰는 동적 클라이언트 등록이에요.

MCP 서버가 OAuth를 쓰면 누가 무엇을 하나요?

MCP 서버의 일이 가장 작아요. 베어러 토큰을 받아들이거나 거부하는 것뿐이에요. MCP 인가 명세(2025-11-25 버전)은 MCP 서버를 OAuth 2.1 리소스 서버로, 어시스턴트를 OAuth 클라이언트로 두고, 사용자를 로그인시키고 토큰을 발급하는 인가 서버는 별도 서비스로 둘 수 있게 해요.

저희는 따로 분리되어 있어요. 모든 토큰은 Supabase Auth의 OAuth 2.1 서버가 발급하고, 로그인과 동의 페이지는 저희 웹사이트가 제공하며, https://mcp.scorestarling.com/mcp 엔드포인트는 토큰 검증만 해요. 이 엔드포인트는 MCP 세션 없이 Streamable HTTP로 동작하고 요청마다 토큰을 확인해요. ScoreStarling은 녹음을 편집 가능한 악보로 바꿔 주는 서비스이고, 어시스턴트가 이 엔드포인트로 연결돼요. 사용자가 따라 하는 단계는 연결 가이드에 있어요.

MCP 클라이언트가 연결되면 어떤 일이 일어나나요?

클라이언트는 URL 하나만 가지고 시작해요. 저희 서버의 응답 두 개가 로그인할 곳을 알려 주고(2026년 10월 3일 기준), 이어지는 여섯 단계로 토큰을 받아요. 먼저, 토큰 없는 요청은 401을 받고, 그 WWW-Authenticate 헤더가 메타데이터 위치와 요청할 스코프를 알려 줘요:

$ curl -si -X POST https://mcp.scorestarling.com/mcp \
    -H 'content-type: application/json' \
    -H 'accept: application/json, text/event-stream' -d '{}'
HTTP/2 401
content-type: application/json
www-authenticate: Bearer resource_metadata="https://mcp.scorestarling.com/.well-known/oauth-protected-resource/mcp", scope="openid email profile"

{"error_description": "Authentication required"}

둘째, 클라이언트가 그 메타데이터를 가져와요. RFC 9728은 호스트와 경로 사이에 /.well-known/oauth-protected-resource를 넣어 주소를 만들도록 하고, resource가 클라이언트가 쓰는 URL과 똑같아야 하며 그렇지 않으면 클라이언트가 중단해야 한다고 요구해요. 여기서는 저희 Supabase 프로젝트 참조 값을 다른 값으로 바꿨어요:

{
  "resource": "https://mcp.scorestarling.com/mcp",
  "authorization_servers": ["https://<project-ref>.supabase.co/auth/v1"],
  "scopes_supported": ["openid", "email", "profile"],
  "bearer_methods_supported": ["header"]
}

나머지 여섯 단계예요:

  1. 클라이언트가 인가 서버의 메타데이터를 읽어요. Supabase의 발급자(issuer)에는 경로가 있으므로 RFC 8414에 따라 well-known 세그먼트가 그 앞에 와요: https://<project-ref>.supabase.co/.well-known/oauth-authorization-server/auth/v1. 클라이언트는 issuer가 정확히 일치하는지, code_challenge_methods_supported에 S256이 있는지 확인해요. 이 필드가 없으면 중단하라고 MCP 명세가 정해 두었어요.

  2. 메타데이터에 공개된 registration_endpoint로 자신의 메타데이터를 보내고(동적 클라이언트 등록, RFC 7591) client_id를 받아요. 저희 테스트 클라이언트는 클라이언트 시크릿 없는 퍼블릭 클라이언트로 등록하고, Anthropic 문서에 따르면 Claude도 그래요.

  3. PKCE S256 챌린지, 401에서 받은 스코프, resource=https://mcp.scorestarling.com/mcp(RFC 8707)와 함께 브라우저에서 인가 엔드포인트를 열어요. Supabase는 브라우저를 저희 동의 페이지로 넘기고, 이 페이지는 필요하면 사용자를 로그인시킨 뒤 클라이언트를 승인할지 거부할지 물어요.

  4. PKCE 검증값(verifier)과 같은 resource로 코드를 교환하고, 1시간 동안 유효한 액세스 토큰과 리프레시 토큰을 받아요.

  5. 모든 MCP 요청에 Authorization: Bearer …를 보내요. 저희는 발급자가 공개한 키로 서명을 확인하고, 발급자, 오디언스, 만료 시각, 쓰기 스코프를 확인한 다음, 계정이 비활성화되지 않았는지 확인해요.

  6. 토큰이 만료되거나 저희가 invalid_token이 담긴 401로 답하면, 이번에도 resource를 넣어 갱신하고 새 액세스 토큰과 리프레시 토큰을 받아요.

인가 서버가 무시하더라도 클라이언트는 인가 요청과 토큰 요청에 resource를 보내야 해요. Supabase의 OAuth 흐름 문서에는 이 파라미터가 언급되어 있지 않아요(2026년 10월 3일 확인). Supabase는 저희의 모든 실제 실행에서 이 파라미터를 받아들였고, 저희 토큰의 오디언스는 훅에서 와요.

401 WWW-Authenticate 헤더에는 무엇이 들어가야 하나요?

토큰을 보내지 않았다면 메타데이터 위치와 요청할 스코프를 넣고, 오류 코드는 넣지 마세요. RFC 6750 §3.1은 인증 정보가 전혀 없는 요청에는 오류 코드를 주지 않아야 한다고 해요. invalid_token은 보낸 토큰이 실패했을 때 쓰고, 새 토큰을 받아 다시 시도해도 된다는 뜻이에요. MCP 명세는 401에 scope 힌트를 담아야 하고 클라이언트는 이를 요청할 스코프로 다뤄야 한다고 덧붙여요.

저희 연결 가이드를 쓰다가 저희 서버가 이걸 틀리게 하고 있다는 걸 발견했어요. MCP Python SDK 1.30.0의 RequireAuthMiddleware는 인증되지 않은 모든 요청에 스코프 없이 error="invalid_token"으로 답해요. 그래서 토큰을 한 번도 보낸 적 없는 클라이언트가 토큰이 잘못됐다는 답을 받았어요. 지금은 MCP 라우트를 감싼 작은 ASGI 미들웨어가 SDK의 401만 고쳐 써요. 누구를 들여보낼지는 여전히 SDK가 정하고, 403 insufficient_scope 응답은 건드리지 않아요:

# No token, before the fix (the SDK's default; wrapped for reading)
WWW-Authenticate: Bearer error="invalid_token",
    error_description="Authentication required",
    resource_metadata="…/.well-known/oauth-protected-resource/mcp"

# No token, now
WWW-Authenticate: Bearer
    resource_metadata="…/.well-known/oauth-protected-resource/mcp",
    scope="openid email profile"

# Rejected token: expired, wrong audience, malformed or account turned off
WWW-Authenticate: Bearer error="invalid_token",
    error_description="Invalid or expired access token",
    resource_metadata="…/.well-known/oauth-protected-resource/mcp",
    scope="openid email profile"

힌트에는 Supabase가 부여할 수 있는 스코프인 openid email profile을 적고, scopes_supported와 맞춰요. 이제 저희 배포 점검은 토큰 없는 챌린지에 오류 코드가 들어 있으면 실패해요. 이 수정은 2026년 10월 2일에 병합한 지 4분 이내에 운영 환경에 반영됐어요. Anthropic의 커넥터 문서는 Claude에 대해 규칙을 두 가지 더해요. 로그인은 401에서만 시작되고(200 응답의 WWW-Authenticate는 무시해요), authorization_servers의 첫 번째 항목만 쓴다는 거예요.

Supabase 토큰에 MCP 오디언스와 스코프는 어떻게 넣나요?

커스텀 액세스 토큰 훅으로 넣어요. MCP 명세는 서버가 자기 앞으로 발급된 토큰만 받아야 한다고 하는데, 실제로는 aud를 확인하라는 뜻이에요. 저희는 쓰기 스코프도 따로 두고 싶었는데, Supabase 문서는 자사 OAuth 서버가 커스텀 스코프를 지원하지 않는다고 해요(2026년 10월 3일 확인). 훅은 Supabase Auth가 토큰을 발급하기 전에 실행하는 Postgres 함수이고, 클레임을 다시 쓸 수 있어요. 저희 훅은 마이그레이션에서 조금 단순화한 것이에요:

create or replace function scorestarling.access_token_hook(event jsonb)
returns jsonb language plpgsql stable security invoker set search_path = '' as $$
declare claims jsonb := event->'claims';
begin
  -- Tokens issued to an OAuth client carry its client_id; website sessions don't.
  if coalesce(claims->>'client_id', '') <> '' then
    claims := jsonb_set(claims, '{aud}', '"https://mcp.scorestarling.com/mcp"');
    claims := jsonb_set(claims, '{scope}', '"openid email profile scorestarling:write"');
  end if;
  return jsonb_build_object('claims', claims);
end;
$$;
grant usage on schema scorestarling to supabase_auth_admin;
grant execute on function scorestarling.access_token_hook(jsonb) to supabase_auth_admin;
revoke all on function scorestarling.access_token_hook(jsonb) from public, anon, authenticated;

MCP 쪽은 PyJWT로 그 클레임을 확인한 다음, 자체 규칙 두 가지를 적용해요:

claims = jwt.decode(token, key, algorithms=["RS256", "ES256"],
                    issuer=ISSUER, audience=ACCEPTED_AUDIENCES,
                    options={"require": ["exp", "iat", "iss", "aud", "sub"]})
if "scorestarling:write" not in claims.get("scope", "").split():
    return None  # the client gets 401 invalid_token
# then: has this account been turned off?

ACCEPTED_AUDIENCES는 MCP URL이고, 주소를 옮기는 동안에는 이전 주소도 포함돼요. 클라이언트 ID 검사는 웹사이트 자체 로그인을 걸러 내요. 같은 Supabase 프로젝트의 브라우저 세션 토큰은 발급자와 키가 같지만 client_id가 없어서 MCP 오디언스를 받을 수 없어요.

이 훅에는 대가가 두 가지 있어요. 클라이언트의 resource를 따르지 않고 고정된 오디언스 하나를 찍어요. 인가 서버 하나가 MCP 서버 하나를 맡는 동안에는 문제없어요. 또 저희 동의 페이지는 클라이언트가 요청한 스코프만 보여 줄 수 있어서, 클라이언트를 승인하는 것이 곧 쓰기 스코프를 부여하는 일이 돼요.

열려 있는 이슈 supabase/auth#2820(2026년 9월 20일 등록, 10월 3일까지 답변 없음)은 Supabase의 authorization-details 요청이 퍼블릭 클라이언트, offline_access, resource에 400을 돌려준다고 해요. 저희는 어느 것도 재현하지 못했어요. 저희가 살펴본 Supabase Auth 소스에서는 이 400이 더 이상 대기 상태가 아닌 인가, 예를 들어 두 번 가져온 인가에만 나타나요.

동적 클라이언트 등록인가요, 클라이언트 ID 메타데이터 문서인가요?

인가 서버가 공개한 방식을 쓰세요. 지금 Supabase에서는 동적 클라이언트 등록(DCR)이에요. 2025-11-25 MCP 명세는 클라이언트와 서버가 서로 처음 만날 때 클라이언트 ID 메타데이터 문서(CIMD)를 권장하고, DCR은 하위 호환을 위해 남겨 둬요. 클라이언트는 미리 받은 클라이언트 ID를 먼저 시도하고, 그다음 CIMD, 그다음 DCR을 시도하고, 그래도 안 되면 그때 사용자에게 물어야 해요.

MCP 클라이언트가 클라이언트 ID를 얻는 방법
방식클라이언트가 자신을 알리는 방법서버 메타데이터의 신호Claude 양식의 옵션저희 서버, 2026년 10월 3일
사전 등록미리 만들어 둔 클라이언트 ID를 직접 입력하거나 내장해 둠필요 없음“Use your own OAuth client”(직접 만든 OAuth 클라이언트 사용)제공하지 않음
클라이언트 ID 메타데이터 문서클라이언트의 client_id는 클라이언트가 호스팅하는 JSON 파일을 가리키는 HTTPS URL이며, 서버가 그 파일을 가져와요client_id_metadata_document_supported“Use Claude’s published identity (recommended)”(Claude의 공개 ID 사용, 권장)공개하지 않음
동적 클라이언트 등록메타데이터를 등록 엔드포인트로 보내고 client_id를 받아요registration_endpoint“Register automatically”(자동 등록)공개함

2026년 10월 3일에 다시 읽은 Supabase의 메타데이터에는 등록 엔드포인트, S256, none 토큰 엔드포인트 방식이 나열되어 있고 CIMD 플래그는 없어요. Supabase의 MCP 안내는 대시보드에서 동적 등록을 켜라고 해요.

그래서 Claude의 맞춤 커넥터 양식에서 무엇을 고를지가 정해져요. Anthropic 문서는 권장 옵션인 Claude의 공개 ID를, Anthropic이 호스팅하고 서버가 지원해야 하는 CIMD라고 설명해요. 그래서 저희 연결 가이드는 “Sign in now”(지금 로그인)과 “Register automatically”(자동 등록)을 고르라고 해요. 저희 도구는 계정이 없으면 하나도 쓸 수 없기 때문이에요. Anthropic 개발자 문서는 CIMD가 공개되지 않으면 Claude가 DCR로 되돌아간다고 해요(CIMD를 쓰려면 token_endpoint_auth_methods_supported에 none도 있어야 해요). 저희는 기본값을 그대로 둔 양식은 테스트하지 않았기 때문에 명시적으로 선택하라고 안내해요. 2026년 10월 2일에 읽은 클라이언트 문서는 ChatGPT 개발자 모드, Cursor, VS Code, Gemini CLI가 DCR을 쓴다고 설명해요.

DCR에는 대가가 있어요. Anthropic은 Claude가 새로 연결할 때마다 새 클라이언트를 등록한다고 밝히고, Supabase는 동적 등록을 켜면 어떤 MCP 클라이언트든 내 프로젝트에 등록할 수 있다고 경고해요. 다만 등록만으로 주어지는 권한은 없어요. 사용자가 로그인해서 클라이언트를 직접 승인해야 하고, 저희 토큰 검증 단계는 요청마다 계정을 확인해요. 클라이언트는 어떤 이름으로든 등록할 수 있기 때문에, 저희 동의 페이지는 2026년 10월 6일부터 승인하면 사용자가 어디로 가는지 보여 주고, 앱이 스스로를 ChatGPT나 Claude라고 칭하는데 그 어시스턴트의 주소로 돌아가지 않으면 경고해요.

액세스 토큰이 만료된 뒤에도 MCP 클라이언트는 로그인 상태를 유지하나요?

네. 액세스 토큰은 1시간 동안 유효하고, 클라이언트는 리프레시 토큰으로 갱신해요. 이에 의존하기 전에 저희는 Supabase Auth의 소스(2026년 9월 22일 기준 master)를 읽었어요:

  • 코드를 교환할 때마다 offline_access 유무와 상관없이 리프레시 토큰이 반환돼요;
  • 갱신은 세션 자신의 클라이언트에서 와야 하고, 리프레시 토큰을 교체하며 client_id 클레임을 유지해요;
  • 훅은 갱신 때도 실행되기 때문에(authentication_method가 token_refresh), 저희 오디언스와 스코프가 매번 다시 들어와요.

2026년 10월 2일의 실제 실행도 같았어요. 리프레시 토큰이 교체됐고, 갱신된 액세스 토큰도 동작했어요. Anthropic 문서에 따르면 Claude는 401을 받은 뒤, 그리고 만료 최대 5분 전에 갱신하며, 인가 서버가 scopes_supported에 offline_access를 나열하면 이를 추가해요. Supabase의 메타데이터에는 이 스코프가 나열되어 있고, offline_access도 함께 요청한 저희 점검도 통과했어요.

그래도 세션이 끝나는 경우는 다음과 같아요:

  • 계정이 비활성화되거나 사용자가 삭제 또는 차단된 경우(저희 실제 실행에서는 계정을 비활성화하자 브라우저 세션과 MCP 토큰 둘 다 거부됐어요);
  • 이전 리프레시 토큰을 Supabase의 재사용 허용 간격(기본 10초)이 지난 뒤에 다시 써서 세션 전체가 취소되는 경우;
  • 세션 시간 제한(time-box), 비활성 타임아웃, “single session per user”(사용자당 세션 하나)를 켠 경우. 마지막 항목은 꺼 두세요. 켜 두면 웹사이트 로그인이 어시스턴트의 세션을 끝내요;
  • 사용자가 Supabase 기본 logout으로 웹사이트에서 로그아웃하는 경우. 이러면 어시스턴트의 세션을 포함해 계정의 모든 세션이 끝나요. 저희도 2026년 10월 6일까지는 그랬고, ChatGPT 연결 하나가 더 이상 토큰을 갱신하지 못한 이유로 보여요. 지금은 ‘로그아웃’이 scope=local을 보내서 해당 브라우저의 세션만 끝내요.

Supabase는 취소(revocation) 엔드포인트를 공개하지 않아서(2026년 10월 3일 확인), 접근을 끊는 방법은 계정을 비활성화하는 거예요.

발급자 끝의 슬래시가 왜 중요한가요?

발급자는 문자 하나하나 비교되기 때문이에요. RFC 8414는 메타데이터의 issuer가 클라이언트가 그것을 찾는 데 쓴 값과 똑같아야 한다고 하고, RFC 9728은 resource에도 같은 것을 요구해요. 엄격한 클라이언트에게 https://auth.example.com과 https://auth.example.com/은 서로 다른 발급자예요.

MCP Python SDK는 발급자를 pydantic AnyHttpUrl로 저장하는데, 저희 락 파일에 있는 버전인 pydantic 2.13.5는 https://auth.example.com을 https://auth.example.com/으로 바꿔요. 이렇게 만든 메타데이터는 인가 서버가 공개한 적 없는 발급자를 가리키기 때문에, 저희 서버는 발급자를 설정한 그대로 담은 보호된 리소스 메타데이터를 직접 써요. Supabase의 발급자는 경로로 끝나서 pydantic이 건드리지 않으므로 운영 환경에서는 이 문제를 겪지 않았지만, 경로 없는 오리진 발급자였다면 겪었을 거예요. 저희 배포 점검은 인가 서버의 issuer가 저희 설정과 정확히 같지 않으면 실패해요.

확인한 것과 확인하지 못한 것

첫 근거는 MCP 명세를 따르는 스크립트 클라이언트에서 나왔어요. 2026년 10월 2일에 운영 환경에 대해 한 번 실행하면서 토큰 없는 401, 퍼블릭 클라이언트의 동적 등록, 동의와 resource를 넣은 코드 교환, 교체를 포함한 갱신, 두 토큰으로 tools/list 호출, 계정 비활성화(두 토큰과 브라우저 세션이 모두 거부됨)를 다뤘어요. 두 번째 실행에서는 offline_access도 요청했고, 둘 다 통과했어요. 그 실행은 이전 주소를 썼어요. mcp.scorestarling.com으로 옮긴 이야기는 MCP 서버 주소, 어떻게 정할까? 33개를 조사했어요에 있어요.

그 뒤로 실제 클라이언트가 연결됐어요. ChatGPT의 개발자 모드 앱은 2026년 10월 3일에 mcp.scorestarling.com으로 로그인해 도구를 호출했고, Claude는 10월 6일에 연결해 도구를 실행했어요. 아직 확인하지 못한 것은 다음과 같아요:

  • 토큰이 실제로 만료된 뒤의 갱신: 스크립트의 갱신은 로그인 직후에 실행했고, 실제 클라이언트의 갱신은 추적해 보지 못했어요;
  • Supabase의 호스팅 세션 설정: 다시 읽어 확인하지 못했어요.

출처

  1. Authorization, MCP specification version 2025-11-25, Model Context Protocol
  2. RFC 9728: OAuth 2.0 Protected Resource Metadata, IETF
  3. RFC 6750: The OAuth 2.0 Authorization Framework: Bearer Token Usage, §3.1, IETF
  4. RFC 8707: Resource Indicators for OAuth 2.0, IETF
  5. RFC 7591: OAuth 2.0 Dynamic Client Registration Protocol, IETF
  6. RFC 8414: OAuth 2.0 Authorization Server Metadata, IETF
  7. OAuth Client ID Metadata Document (Internet-Draft), IETF OAuth 워킹 그룹
  8. Authentication for connectors, Anthropic
  9. Add a connector that isn’t in the directory, Anthropic
  10. OAuth 2.1 Flows, Supabase
  11. Custom Access Token Hook, Supabase
  12. Model Context Protocol (MCP) Authentication, Supabase
  13. User sessions, Supabase
  14. supabase/auth issue #2820, GitHub

질문과 답변

원격 MCP 서버에 자체 인가 서버가 필요한가요?

아니요. MCP 인가 명세는 인가 서버를 별도 서비스로 두도록 허용해요. MCP 서버는 인가 서버를 가리키는 RFC 9728 메타데이터를 공개하고, 그 서버가 발급한 토큰을 오디언스(audience)까지 포함해 검증하면 돼요. 저희 서버는 Supabase Auth를 가리키며 토큰만 검증해요.

토큰을 보내지 않았을 때도 MCP 서버의 401에 invalid_token 오류를 넣어야 하나요?

아니요. RFC 6750 §3.1은 인증 정보 없이 온 요청에는 오류 코드를 주지 않아야 한다고 해요. 대신 resource_metadata와 scope 힌트를 보내고, invalid_token은 보낸 토큰이 거부된 경우에만 쓰세요. 그때는 클라이언트에게 토큰을 갱신하라고 알려 주는 역할을 해요.

Supabase Auth가 MCP 서버용 커스텀 스코프를 발급할 수 있나요?

2026년 10월 3일 기준으로는 안 돼요. Supabase의 OAuth 2.1 문서에 커스텀 스코프를 지원하지 않는다고 나와 있어요. 저희는 openid email profile을 요청하고, 저희 스코프와 MCP 오디언스는 커스텀 액세스 토큰 훅에서 추가해요. 이 훅은 토큰을 갱신할 때마다 실행돼요.

서버가 CIMD를 지원하지 않으면 Claude의 맞춤 커넥터 양식에서 무엇을 골라야 하나요?

“OAuth client”(OAuth 클라이언트) 항목에서 “Register automatically”(자동 등록)을 고르세요. 동적 클라이언트 등록을 쓰는 방식이에요. 모든 도구에 계정이 필요하다면 “Authentication”(인증) 항목에서는 “Sign in now”(지금 로그인)을 고르세요. Claude가 권장하는 공개된 ID는 클라이언트 ID 메타데이터 문서이고, 서버가 이를 지원해야 해요. 저희 Claude 연결 단계에서 두 선택을 모두 볼 수 있어요.

Supabase에서 리프레시 토큰을 받으려면 offline_access 스코프가 필요한가요?

저희 테스트에서는 필요 없었어요. 소스 코드에서도, 2026년 10월 2일의 실제 실행에서도 Supabase는 이 스코프 없이 코드를 교환할 때마다 리프레시 토큰을 돌려줬어요. 일부 클라이언트는 어쨌든 요청해요. Anthropic 문서에 따르면 Claude는 인가 서버가 이 스코프를 나열하면 요청하는데, 이 스코프를 넣은 저희 점검도 통과했어요.

어떤 음악이든 가져오세요 악보를 챙겨 가세요