블로그 • Agent Security
MCP 클라이언트가 OAuth 자격 증명을 잘못된 인가 서버로 보낸 뒤, JSON 로그는 어느 단계에서 Tool Call을 멈춰야 하나?
2026년 9월 30일 MCP TypeScript SDK가 GHSA-6qxp-vccf-f47h를 공개했다. 심각도는 High, CVSS 7.5다. 영향 버전에서 OAuth 클라이언트는 저장된 자격 증명을 그것이 속한 인가 서버에 묶어 두지 않았다. 연결 중인 MCP 서버가 다른 인가 서버를 가리키면, 클라이언트는 사용자 조작 없이 자격 증명을 보냈다. 권고 뒤에 남겨야 하는 것은 Tool Call을 멈추는 JSON 한 줄이지, 다시 실행할 수 있는 요청이 아니다.
MCP 권한을 누가 맡는지는 MCP 보안 글. Schema를 통과한 뒤의 권한 결정은 이전 글. 이 글은 권고 뒤의 로그에서 시작한다. credential_check가 통과하지 않으면 그다음 Tool Call은 없어야 한다.
- savedIssuer와 MCP 서버가 가리킨 namedAuthorizationServer를 비교한다.
- issuerMatch가 false이면 decision은 deny, credentialsSent는 false로 둔다.
- 이 단계가 이미 deny이면 로그에 tool_call을 덧붙이지 않는다.
- SDK 1.31.0 또는 client 2.2.0으로 올린 뒤에도 묶음 provider에는 expectedIssuer를 적는다. issuer가 없는 옛 저장 자격 증명은 그 필드를 넣거나, 지운 뒤 다시 로그인한다.
결론: issuer가 다르면 credential_check에서 멈춘다. decision은 deny, credentialsSent는 false이고, 그다음에 Tool Call을 적지 않는다.
권고 뒤, 로그의 첫 줄에는 무엇을 적나?
영향받는 것은 SDK를 HTTP 위의 OAuth 클라이언트로 쓰고, 완전히 신뢰하지 않는 MCP 서버에 연결할 수 있으며, 정당한 인가 서버의 자격 증명을 가진 앱이다. 입구는 전송의 authProvider, withOAuth 미들웨어, auth 또는 fetchToken의 직접 호출이다. 이 SDK로 만든 MCP 서버 자체와 stdio 클라이언트는 범위 밖이다. 1.x는 1.12.0부터 1.30.1까지이고 수정은 1.31.0이다. 2.x client는 2.0.0부터 2.1.0까지인데, expectedIssuer가 없는 묶음 provider, issuer가 없는 저장 자격 증명, fetchToken 직접 호출, 또는 OAuthTokensSchema와 OAuthClientInformationSchema로 저장을 다시 읽는 경우에만 영향받고, 수정은 2.2.0이다. core를 직접 가져오면 그것도 2.2.0으로 올린다. 원문은 GHSA-6qxp-vccf-f47h.
{
"step": "credential_check",
"savedIssuer": "https://auth.example.com",
"namedAuthorizationServer": "https://other.example.net",
"issuerMatch": false,
"credentialsSent": false,
"decision": "deny",
"reason": "issuer_mismatch"
}
이 줄은 정지점만 기록한다. savedIssuer는 자격 증명이 원래 속한 인가 서버다. namedAuthorizationServer는 이번 MCP 서버가 가리킨 쪽이다. 두 문자열이 다르면 issuerMatch는 false다. decision은 deny, credentialsSent는 false다. 칸에 token이나 client secret을 넣지 않는다. 취약점 목록은 같은 문제를 CVE-2026-104850으로 적는다. 이 글은 GitHub 권고의 버전과 수정을 따른다.
이 JSON 줄의 모양은 어떤 Schema로 고정하나?
JSON Schema로 열거와 필수 필드를 고정한다. 이것이 credential_check라는 것을 증명한다. decision은 allow, deny, ask만 가능하다. reason은 issuer_mismatch, issuer_match, awaiting_user만 가능하다. 이번에 멈춰야 하는지는 증명하지 않는다.
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"required": ["step", "savedIssuer", "namedAuthorizationServer", "issuerMatch", "credentialsSent", "decision", "reason"],
"properties": {
"step": { "enum": ["credential_check"] },
"savedIssuer": { "type": "string" },
"namedAuthorizationServer": { "type": "string" },
"issuerMatch": { "type": "boolean" },
"credentialsSent": { "type": "boolean" },
"decision": { "enum": ["allow", "deny", "ask"] },
"reason": { "enum": ["issuer_mismatch", "issuer_match", "awaiting_user"] }
}
}
모양 통과는 필드가 열거 안에 있다는 뜻만 가진다. issuerMatch가 false, credentialsSent가 true, decision이 allow여도 모양은 맞다. 전송을 멈추는 것은 다음 절의 앱 규칙이지, 이번 모양 확인이 아니다.
모양이 통과해도 어느 세 규칙이 여전히 멈추나?
권고의 수정은 이렇다. 클라이언트는 인가 서버를 자격 증명의 issuer로 기록하고, 다른 서버로는 보내지 않는다. 사용자는 다시 로그인하거나, 그 호출이 실패한다. 비교는 모델 밖에서 한다. 모델이 나중에 무엇을 말하든, 이미 deny로 적힌 단계를 전송으로 바꿀 수 없다.
| 기록에 보이는 것 | Schema | 앱 규칙 |
|---|---|---|
| issuerMatch는 false인데 credentialsSent는 true | 통과한다. true는 불리언이다. | false로 되돌린다. refresh_token도 client_secret도 서명된 assertion도 보내지 않는다. |
| issuerMatch는 false인데 decision은 allow | 통과한다. allow는 열거 안에 있다. | deny로 바꾸고 reason은 issuer_mismatch로 둔다. |
| credential_check는 이미 deny인데 뒤에 tool_call 한 줄이 있다 | 한 줄의 Schema는 다음 줄을 보지 못한다. step을 tool_call로 바꾸면 이 기록 자체가 실패한다. | 로그는 credential_check에서 끝난다. Tool Call을 덧붙이지 않는다. issuer가 이미 일치하는 다른 호출만 따로 권한 결정을 적는다. |
버전을 올린 뒤에도 어느 세 곳을 손으로 적나?
1.x는 @modelcontextprotocol/sdk 1.31.0 이상으로 올린다. 2.x는 @modelcontextprotocol/client 2.2.0 이상으로 올린다. 패키지만 올리면 아래 세 곳은 끝나지 않는다. 근거는 여전히 같은 권고.
| 아직 할 일 | 권고의 요구 |
|---|---|
| 묶음 provider | ClientCredentialsProvider, PrivateKeyJwtProvider, StaticPrivateKeyJwtProvider, CrossAppAccessProvider에 expectedIssuer를 넘긴다. 예는 https://auth.example.com 이다. 적지 않으면 그것들은 여전히 MCP 서버가 가리킨 인가 서버를 쓴다. |
| issuer가 없는 옛 저장 | 업그레이드 전에 파일, 키체인, 데이터베이스에 저장한 token과 클라이언트 정보는 처음 쓸 때 그때 지정된 인가 서버로 여전히 간다. issuer를 넣거나, 지워서 사용자가 다시 로그인하게 한다. 직접 만든 OAuthClientProvider는 saveTokens와 saveClientInformation이 준 내용을 issuer까지 그대로 저장한다. 미리 등록한 자격 증명은 clientInformation의 반환값에 issuer를 넣는다. |
| 새 대화형 로그인 | 이번 수정은 새 대화형 로그인을 덮지 않는다. 그 로그인은 여전히 MCP 서버가 가리킨 인가 서버로 간다. 신뢰하는 서버에 대해서만 로그인을 마친다. 클라이언트가 신뢰하지 않는 MCP 서버에 연결했을 수 있다면 client secret 또는 서명 키를 교체하고 token을 취소한다. 아직 업그레이드할 수 없으면 OAuth 클라이언트는 신뢰하는 MCP 서버에만 연결한다. 2.0.0과 2.1.0은 이미 expectedIssuer를 받는다. |
refreshAuthorization 또는 exchangeAuthorization을 직접 호출하는 것도 이번 수정 밖이다. 2.x에서 skipIssuerMetadataValidation을 true로 둔 경우도 밖이다. 둘 다 자격 증명을 보낼지를 모델에게 판단시키지 않는다.
이미 있는 두 글과 무엇이 다른가?
| 왜 Schema가 필요한가 | MCP 보안 | 이 글 | |
|---|---|---|---|
| 답하는 것 | 출력에 왜 모양이 필요한가 | 권한을 누가 지켜야 하는가 | issuer가 다를 때 로그의 어느 단계에서 Tool Call을 멈추는가 |
| 쓰지 않는 것 | 이번 OAuth 권고의 정지점은 쓰지 않는다 | 권고 뒤의 로그 순서는 쓰지 않는다 | 다시 실행할 수 있는 요청은 쓰지 않고, Schema가 충분한지도 다시 말하지 않는다 |
JSONNote에서 이 정지점을 어떻게 확인하나?
- 1동작을 고른다
JSON Schema를 연다. 작동하다 를 Schema로 확인하세요 로 바꾼다. 첫 칸에 위의 정지점, 둘째 칸에 Schema를 붙인다. step은 credential_check만. decision은 allow, deny, ask만.
- 2나쁜 데이터 넷을 부딪힌다
decision을 yes로 하거나, credentialsSent를 문자열 false로 하거나, decision을 지우거나, step을 tool_call로 한다. 이 넷은 실패한다. 위의 deny 기록은 통과한다. 통과는 모양이 맞다는 뜻만 가진다.
- 3deny와 모양은 맞는 allow를 비교한다
JSON 비교 Diff. 첫 칸에 deny 정지점을 붙인다. 둘째 칸은 credentialsSent만 true, decision만 allow로 바꾼다. reason은 issuer_mismatch다. 둘 다 Schema는 통과한다. 전송을 막는 것은 앱 규칙이다. issuer가 다르면 deny여야 하고, 아무것도 보내지 않는다.
자주 묻는 질문
stdio 클라이언트도 이 로그를 써야 하나?
쓰지 않아도 된다. 권고는 stdio 클라이언트와 이 SDK로 만든 MCP 서버는 영향 범위 밖이라고 적는다. credential_check를 쓰는 것은 HTTP 위의 OAuth 클라이언트이며, 완전히 신뢰하지 않는 MCP 서버에 연결할 수 있고 정당한 인가 서버의 자격 증명을 가진 경우다.
1.31.0 또는 2.2.0으로 올리면 충분한가?
충분하지 않다. 묶음 provider에는 expectedIssuer를 적는다. issuer가 없는 옛 자격 증명은 필드를 넣거나 지운다. 새 대화형 로그인은 신뢰하는 서버에 대해서만 마친다. refreshAuthorization 또는 exchangeAuthorization의 직접 호출과 skipIssuerMetadataValidation을 true로 두는 것은 이번 수정 밖이다.
issuer가 일치하면 Tool Call을 실행해도 되나?
그것만으로는 실행하면 안 된다. issuer가 일치한다는 것은 자격 증명이 다른 인가 서버로 가지 않았다는 뜻만 가진다. Tool Call에는 아직 따로 권한 결정이 필요하다. 도구가 허용 목록에 있는지, 이번 grant에 그 능력이 있는지. 그것은 이전 글의 일이다. 이 글은 deny 뒤에 그 호출을 적지 않는다.
MCP 보안 글과 같은 글인가?
같은 글이 아니다. 그 글은 권한을 누가 지켜야 하는지를 쓴다. 이 글은 2026-09-30 권고 뒤에 JSON 로그의 어느 단계에서 Tool Call을 멈추는지를 쓴다. 둘 다 도구로 이어지지만 자르는 면이 다르다.
로그에 refresh_token을 저장하나?
저장하지 않는다. 정지점이 남기는 것은 issuer 문자열 둘, 일치 여부, 전송 여부, decision, reason뿐이다. token과 client secret은 이 JSON에 넣지 않는다. 신뢰하지 않는 서버에 연결했을 수 있다면 로그에 베끼지 말고, 키를 교체하고 token을 취소한다.
정리
자격 증명은 먼저 원래 issuer에 묶는다. MCP 서버가 다른 인가 서버를 가리키면 로그는 credential_check에서 멈춘다. deny이고, 보내지 않으며, Tool Call도 적지 않는다. 버전 업그레이드는 이 비교를 클라이언트에 넣는다. expectedIssuer와 옛 저장은 여전히 직접 채운다.
결론: 먼저 issuer를 비교한다. 다르면 deny. credentialsSent는 false로 둔다. Tool Call은 덧붙이지 않는다. 묶음 provider에는 expectedIssuer를 적는다. 신뢰하지 않는 서버에 연결했을 수 있다면 키를 교체하고 token을 취소한다.
다음: 이 정지점을 Schema로 고정한 뒤, Diff로 deny와 모양은 맞지만 decision이 allow인 기록의 차이를 본다.